diff --git a/events.md b/events.md new file mode 100644 index 000000000..fc2087437 --- /dev/null +++ b/events.md @@ -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 + +``` + +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 + + +### Index +- approveComment +- banUser +- cancelAccountDeletion +- changeEmail +- changePassword +- changeUsername +- closeStory +- copyPermalink +- createComment +- createCommentFocus +- createCommentReaction +- createCommentReply +- editComment +- featureComment +- gotoModeration +- ignoreUser +- loadMoreAllComments +- loadMoreFeaturedComments +- loadMoreHistoryComments +- loginPrompt +- openSortMenu +- openStory +- rejectComment +- removeCommentReaction +- removeUserIgnore +- replyCommentFocus +- reportComment +- requestAccountDeletion +- requestDownloadCommentHistory +- resendEmailVerification +- setCommentsOrderBy +- setCommentsTab +- setMainTab +- setProfileTab +- showAbsoluteTimestamp +- showAllReplies +- showAuthPopup +- showEditEmailDialog +- showEditForm +- showEditPasswordDialog +- showEditUsernameDialog +- showFeaturedCommentTooltip +- showIgnoreUserdDialog +- showModerationPopover +- showMoreOfConversation +- showMoreReplies +- showReplyForm +- showReportPopover +- showSharePopover +- showUserPopover +- signOut +- unfeatureComment +- updateNotificationSettings +- updateStorySettings +- viewConversation +- viewFullDiscussion +- viewNewComments + +### Events +- **approveComment.success**, **approveComment.error**: This event is emitted when the viewer approves a comment. + ```ts + { + commentID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **banUser.success**, **banUser.error**: This event is emitted when the viewer bans a user. + ```ts + { + userID: string; + commentID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **cancelAccountDeletion.success**, **cancelAccountDeletion.error**: This event is emitted when the viewer cancels the account deletion. + ```ts + { + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **changeEmail.success**, **changeEmail.error**: This event is emitted when the viewer changes its email. + ```ts + { + oldEmail: string; + newEmail: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **changePassword.success**, **changePassword.error**: This event is emitted when the viewer changes its password. + ```ts + { + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **changeUsername.success**, **changeUsername.error**: This event is emitted when the viewer changes its username. + ```ts + { + oldUsername: string; + newUsername: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **closeStory.success**, **closeStory.error**: This event is emitted when the viewer closes the story. + ```ts + { + storyID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **copyPermalink**: This event is emitted when the viewer copies the permalink with the button. + ```ts + { + commentID: string; + } + ``` +- **createComment.success**, **createComment.error**: 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; + }; + } + ``` +- **createCommentFocus**: This event is emitted when the viewer focus on the RTE to create a comment. +- **createCommentReaction.success**, **createCommentReaction.error**: This event is emitted when the viewer reacts to a comment. + ```ts + { + commentID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **createCommentReply.success**, **createCommentReply.error**: 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; + }; + } + ``` +- **editComment.success**, **editComment.error**: 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; + }; + } + ``` +- **featureComment.success**, **featureComment.error**: This event is emitted when the viewer features a comment. + ```ts + { + commentID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **gotoModeration**: This event is emitted when the viewer goes to moderation. + ```ts + { + commentID: string; + } + ``` +- **ignoreUser.success**, **ignoreUser.error**: This event is emitted when the viewer ignores a user. + ```ts + { + userID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **loadMoreAllComments.success**, **loadMoreAllComments.error**: 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; + }; + } + ``` +- **loadMoreFeaturedComments.success**, **loadMoreFeaturedComments.error**: This event is emitted when the viewer loads more featured comments. + ```ts + { + storyID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **loadMoreHistoryComments.success**, **loadMoreHistoryComments.error**: 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; + }; + } + ``` +- **loginPrompt**: This event is emitted when the viewer does an action that will prompt a login dialog. +- **openSortMenu**: This event is emitted when the viewer clicks on the sort menu. +- **openStory.success**, **openStory.error**: This event is emitted when the viewer opens the story. + ```ts + { + storyID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **rejectComment.success**, **rejectComment.error**: This event is emitted when the viewer rejects a comment. + ```ts + { + commentID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **removeCommentReaction.success**, **removeCommentReaction.error**: This event is emitted when the viewer removes its reaction from a comment. + ```ts + { + commentID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **removeUserIgnore.success**, **removeUserIgnore.error**: 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; + }; + } + ``` +- **replyCommentFocus**: This event is emitted when the viewer focus on the RTE to reply to a comment. +- **reportComment.success**, **reportComment.error**: 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; + }; + } + ``` +- **requestAccountDeletion.success**, **requestAccountDeletion.error**: This event is emitted when the viewer requests to delete its account. + ```ts + { + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **requestDownloadCommentHistory.success**, **requestDownloadCommentHistory.error**: This event is emitted when the viewer requests to download its comment history. + ```ts + { + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **resendEmailVerification.success**, **resendEmailVerification.error**: This event is emitted when the viewer request another email verification email. + ```ts + { + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **setCommentsOrderBy**: This event is emitted when the viewer changes the sort order of the comments. + ```ts + { + orderBy: string; + } + ``` +- **setCommentsTab**: This event is emitted when the viewer changes the tab of the comments tab bar. + ```ts + { + tab: string; + } + ``` +- **setMainTab**: This event is emitted when the viewer changes the tab of the main tab bar. + ```ts + { + tab: string; + } + ``` +- **setProfileTab**: This event is emitted when the viewer changes the tab of the profile tab bar. + ```ts + { + tab: string; + } + ``` +- **showAbsoluteTimestamp**: This event is emitted when the viewer clicks on the relative timestamp to show the absolute time. +- **showAllReplies.success**, **showAllReplies.error**: This event is emitted when the viewer reveals all replies of a comment. + ```ts + { + commentID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **showAuthPopup**: This event is emitted when the viewer requests the auth popup. + ```ts + { + view: string; + } + ``` +- **showEditEmailDialog**: This event is emitted when the viewer opens the edit email dialog. +- **showEditForm**: This event is emitted when the viewer opens the edit form. + ```ts + { + commentID: string; + } + ``` +- **showEditPasswordDialog**: This event is emitted when the viewer opens the edit password dialog. +- **showEditUsernameDialog**: This event is emitted when the viewer opens the edit username dialog. +- **showFeaturedCommentTooltip**: This event is emitted when the viewer clicks to show the featured comment tooltip. +- **showIgnoreUserdDialog**: This event is emitted when the viewer opens the ignore user dialog. +- **showModerationPopover**: This event is emitted when the viewer opens the moderation popover. + ```ts + { + commentID: string; + } + ``` +- **showMoreOfConversation.success**, **showMoreOfConversation.error**: 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; + }; + } + ``` +- **showMoreReplies**: This event is emitted when the viewer reveals new live replies. + ```ts + { + commentID: string; + count: number; + } + ``` +- **showReplyForm**: This event is emitted when the viewer opens the reply form. + ```ts + { + commentID: string; + } + ``` +- **showReportPopover**: This event is emitted when the viewer opens the report popover. + ```ts + { + commentID: string; + } + ``` +- **showSharePopover**: This event is emitted when the viewer opens the share popover. + ```ts + { + commentID: string; + } + ``` +- **showUserPopover**: This event is emitted when the viewer clicks on a username which shows the user popover. + ```ts + { + userID: string; + } + ``` +- **signOut.success**, **signOut.error**: This event is emitted when the viewer signs out. + ```ts + { + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **unfeatureComment.success**, **unfeatureComment.error**: This event is emitted when the viewer unfeatures a comment. + ```ts + { + commentID: string; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` +- **updateNotificationSettings.success**, **updateNotificationSettings.error**: 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; + }; + } + ``` +- **updateStorySettings.success**, **updateStorySettings.error**: 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; + }; + } + ``` +- **viewConversation**: This event is emitted when the viewer changes to the single conversation view. + ```ts + { + from: "FEATURED_COMMENTS" | "COMMENT_STREAM" | "COMMENT_HISTORY"; + commentID: string; + } + ``` +- **viewFullDiscussion**: This event is emitted when the viewer exits the single conversation. + ```ts + { + commentID: string | null; + } + ``` +- **viewNewComments**: This event is emitted when the viewer reveals new live comments. + ```ts + { + storyID: string; + count: number; + } + ``` + diff --git a/package-lock.json b/package-lock.json index 63ef27e31..20fb294e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 47e6bd663..8e3a2b96b 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/scripts/generateEventDocs.ts b/scripts/generateEventDocs.ts new file mode 100644 index 000000000..34019928a --- /dev/null +++ b/scripts/generateEventDocs.ts @@ -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 = /(.|\n)*/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 => `${getEventName(e.name)}` + ) + .join("\n - ")} + `; + const list = entries + .map( + e => + codeBlock` + - ${ + e.type === "ViewerEvent" + ? `**${getEventName(e.name)}**` + : `**${getEventName( + e.name + )}.success**, **${getEventName(e.name)}.error**` + }: ${e.docs ? e.docs.replace("\n", " ") : ""} + ${ + e.text + ? codeBlock` + \`\`\`ts + ${removeFutureAddedValue(e.text)} + \`\`\` + ` + : "" + } + ` + ) + .join("\n"); + + const output = stripIndent` + + + ### Index + ${prefixLines(summary, " ")} + + ### Events + ${prefixLines(list, " ")} + + `; + + 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(); diff --git a/src/core/client/framework/lib/events.ts b/src/core/client/framework/lib/events.ts new file mode 100644 index 000000000..9a88839cb --- /dev/null +++ b/src/core/client/framework/lib/events.ts @@ -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 { + 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 + : ( + eventEmitter: EventEmitter2, + data: Pick> + ) => ViewerNetworkEventStarted; +} + +/** + * createViewerEvent creates a ViewerNetworkEvent object. + * + * @param name name of the event + */ +export function createViewerNetworkEvent< + T extends { success: object; error: object } +>(name: string): ViewerNetworkEvent { + 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["success"], + error: (error => { + const final: any = { + ...data, + rtt: Date.now() - ms, + }; + if (error) { + final.error = error; + } + eventEmitter.emit(`${name}.error`, final); + }) as ViewerNetworkEventStarted["error"], + }; + }) as ViewerNetworkEvent["begin"], + }; +} + +/** + * createViewerEvent creates a ViewerEvent object. + * + * @param name name of the event + */ +export function createViewerEvent(name: string): ViewerEvent { + return { + emit: ((eventEmitter, data) => { + eventEmitter.emit(name, data); + }) as ViewerEvent["emit"], + }; +} + +/** + * useViewerEvent inject the eventEmitter and returns a simple + * callback to emit the event. + */ +export function useViewerEvent( + viewerEvent: ViewerEvent +): 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 +): keyof T extends "success" | "error" + ? () => ViewerNetworkEventStarted + : ( + data: Pick> + ) => ViewerNetworkEventStarted { + const { eventEmitter } = useCoralContext(); + return ((data?: T) => { + return viewerNetworkEvent.begin(eventEmitter, data as any); + }) as any; +} diff --git a/src/core/client/framework/lib/relay/createMutationContainer.tsx b/src/core/client/framework/lib/relay/createMutationContainer.tsx index 53289b00a..1a11eabdc 100644 --- a/src/core/client/framework/lib/relay/createMutationContainer.tsx +++ b/src/core/client/framework/lib/relay/createMutationContainer.tsx @@ -38,7 +38,6 @@ function createMutationContainer( ); private commit = (input: I) => { - this.props.context.eventEmitter.emit(`mutation.${propName}`, input); return commit( this.props.context.relayEnvironment, input, diff --git a/src/core/client/framework/lib/relay/fetch.tsx b/src/core/client/framework/lib/relay/fetch.tsx index d5a865edc..7972d7853 100644 --- a/src/core/client/framework/lib/relay/fetch.tsx +++ b/src/core/client/framework/lib/relay/fetch.tsx @@ -69,7 +69,6 @@ export function useFetch( const context = useCoralContext(); return useCallback>( ((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( 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, diff --git a/src/core/client/framework/lib/relay/mutation.tsx b/src/core/client/framework/lib/relay/mutation.tsx index 8ff80b112..312728f96 100644 --- a/src/core/client/framework/lib/relay/mutation.tsx +++ b/src/core/client/framework/lib/relay/mutation.tsx @@ -71,7 +71,6 @@ export function useMutation( const context = useCoralContext(); return useCallback>( ((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( ); private commit = (input: I) => { - this.props.context.eventEmitter.emit( - `mutation.${mutation.name}`, - input - ); return mutation.commit( this.props.context.relayEnvironment, input, diff --git a/src/core/client/framework/lib/relay/subscription.tsx b/src/core/client/framework/lib/relay/subscription.tsx index cf344e890..8f79356cf 100644 --- a/src/core/client/framework/lib/relay/subscription.tsx +++ b/src/core/client/framework/lib/relay/subscription.tsx @@ -50,7 +50,6 @@ export function useSubscription( const context = useCoralContext(); return useCallback>( ((variables: V) => { - context.eventEmitter.emit(`subscription.${subscription.name}`, variables); return subscription.subscribe( context.relayEnvironment, variables, diff --git a/src/core/client/framework/lib/relay/useLoadMore.ts b/src/core/client/framework/lib/relay/useLoadMore.ts index 97c20b75b..4be7e0585 100644 --- a/src/core/client/framework/lib/relay/useLoadMore.ts +++ b/src/core/client/framework/lib/relay/useLoadMore.ts @@ -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, 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((resolve, reject) => { + relay.loadMore(count, error => { + setIsLoadingMore(false); + if (error) { + reject(error); + } else { + resolve(); + } + }); }); }, [relay]); return [loadMore, isLoadingMore]; diff --git a/src/core/client/stream/App/SetActiveTabMutation.spec.ts b/src/core/client/stream/App/SetActiveTabMutation.spec.ts index b571e8beb..182bc1c63 100644 --- a/src/core/client/stream/App/SetActiveTabMutation.spec.ts +++ b/src/core/client/stream/App/SetActiveTabMutation.spec.ts @@ -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(); }); diff --git a/src/core/client/stream/App/SetActiveTabMutation.ts b/src/core/client/stream/App/SetActiveTabMutation.ts index cc7740bbf..c18059001 100644 --- a/src/core/client/stream/App/SetActiveTabMutation.ts +++ b/src/core/client/stream/App/SetActiveTabMutation.ts @@ -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; export async function commit( environment: Environment, - input: SetActiveTabInput + input: SetActiveTabInput, + { eventEmitter }: Pick ) { 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"); + } }); } diff --git a/src/core/client/stream/App/listeners/OnEvents.spec.tsx b/src/core/client/stream/App/listeners/OnEvents.spec.tsx index 044376582..bcf954d95 100644 --- a/src/core/client/stream/App/listeners/OnEvents.spec.tsx +++ b/src/core/client/stream/App/listeners/OnEvents.spec.tsx @@ -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( - - ); - expect(eventEmitter.emit.calledOnce).toBe(true); -}); diff --git a/src/core/client/stream/App/listeners/OnEvents.tsx b/src/core/client/stream/App/listeners/OnEvents.tsx index 05978ddd8..33ab64943 100644 --- a/src/core/client/stream/App/listeners/OnEvents.tsx +++ b/src/core/client/stream/App/listeners/OnEvents.tsx @@ -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 { 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({ diff --git a/src/core/client/stream/App/listeners/emitEventAliases.ts b/src/core/client/stream/App/listeners/emitEventAliases.ts deleted file mode 100644 index 9bff78c25..000000000 --- a/src/core/client/stream/App/listeners/emitEventAliases.ts +++ /dev/null @@ -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; - } -} diff --git a/src/core/client/stream/common/Timestamp.tsx b/src/core/client/stream/common/Timestamp.tsx new file mode 100644 index 000000000..5ab9ee65a --- /dev/null +++ b/src/core/client/stream/common/Timestamp.tsx @@ -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 +> = props => { + const emitEvent = useViewerEvent(ShowAbsoluteTimestampEvent); + const handleOnToggle = useCallback( + (absolute: boolean) => { + if (absolute) { + emitEvent(); + } + if (props.onToggleAbsolute) { + return props.onToggleAbsolute(absolute); + } + }, + [props.onToggleAbsolute, emitEvent] + ); + return ; +}; + +export default TimeStamp; diff --git a/src/core/client/stream/common/UserBox/UserBoxContainer.tsx b/src/core/client/stream/common/UserBox/UserBoxContainer.tsx index a16647548..81488d1cc 100644 --- a/src/core/client/stream/common/UserBox/UserBoxContainer.tsx +++ b/src/core/client/stream/common/UserBox/UserBoxContainer.tsx @@ -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; } export class UserBoxContainer extends Component { @@ -118,7 +117,7 @@ export class UserBoxContainer extends Component { } } -const enhanced = withSignOutMutation( +const enhanced = withMutation(SignOutMutation)( withSetAuthPopupStateMutation( withShowAuthPopupMutation( withLocalStateContainer( diff --git a/src/core/client/stream/events.ts b/src/core/client/stream/events.ts new file mode 100644 index 000000000..708076b3e --- /dev/null +++ b/src/core/client/stream/events.ts @@ -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 + * + * ``` + */ + +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"); diff --git a/src/core/client/stream/mutations/CancelAccountDeletionMutation.tsx b/src/core/client/stream/mutations/CancelAccountDeletionMutation.tsx index 0d3551ee8..5600f8015 100644 --- a/src/core/client/stream/mutations/CancelAccountDeletionMutation.tsx +++ b/src/core/client/stream/mutations/CancelAccountDeletionMutation.tsx @@ -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) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation CancelAccountDeletionMutation( - $input: CancelAccountDeletionInput! - ) { - cancelAccountDeletion(input: $input) { - user { - scheduledDeletionDate + async ( + environment: Environment, + input: MutationInput, + { eventEmitter } + ) => { + const cancelAccountDeletionEvent = CancelAccountDeletionEvent.begin( + eventEmitter + ); + try { + const result = await commitMutationPromiseNormalized( + 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; diff --git a/src/core/client/stream/mutations/ShowAuthPopupMutation.spec.ts b/src/core/client/stream/mutations/ShowAuthPopupMutation.spec.ts index e7432424e..db29f27d6 100644 --- a/src/core/client/stream/mutations/ShowAuthPopupMutation.spec.ts +++ b/src/core/client/stream/mutations/ShowAuthPopupMutation.spec.ts @@ -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"); }); diff --git a/src/core/client/stream/mutations/ShowAuthPopupMutation.ts b/src/core/client/stream/mutations/ShowAuthPopupMutation.ts index 36e0b2515..f0a0dbfdb 100644 --- a/src/core/client/stream/mutations/ShowAuthPopupMutation.ts +++ b/src/core/client/stream/mutations/ShowAuthPopupMutation.ts @@ -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 ) { + 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"); diff --git a/src/core/client/stream/mutations/SignOutMutation.ts b/src/core/client/stream/mutations/SignOutMutation.ts new file mode 100644 index 000000000..f39d1dc1d --- /dev/null +++ b/src/core/client/stream/mutations/SignOutMutation.ts @@ -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; diff --git a/src/core/client/stream/mutations/index.ts b/src/core/client/stream/mutations/index.ts index ff8865430..091744e76 100644 --- a/src/core/client/stream/mutations/index.ts +++ b/src/core/client/stream/mutations/index.ts @@ -7,3 +7,4 @@ export { withShowAuthPopupMutation, ShowAuthPopupMutation, } from "./ShowAuthPopupMutation"; +export { default as SignOutMutation } from "./SignOutMutation"; diff --git a/src/core/client/stream/tabs/Comments/Comment/Comment.tsx b/src/core/client/stream/tabs/Comments/Comment/Comment.tsx index 4a7575e24..b6c79312b 100644 --- a/src/core/client/stream/tabs/Comments/Comment/Comment.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/Comment.tsx @@ -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"; diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx index e70bba0e0..bfc89d7ed 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx @@ -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; function createDefaultProps(add: DeepPartial = {}): Props { return pureMerge( { + eventEmitter: new EventEmitter2(), viewer: null, story: { url: "http://localhost/story", diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx index 645ca3c75..976666ce5 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx @@ -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 { 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 { 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 { } 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 { } } -const enhanced = withSetCommentIDMutation( - withShowAuthPopupMutation( - withFragmentContainer({ - viewer: graphql` - fragment CommentContainer_viewer on User { - id - status { - current - } - ignoredUsers { +const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))( + withSetCommentIDMutation( + withShowAuthPopupMutation( + withFragmentContainer({ + 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) + ) ) ); diff --git a/src/core/client/stream/tabs/Comments/Comment/EditCommentForm/EditCommentForm.tsx b/src/core/client/stream/tabs/Comments/Comment/EditCommentForm/EditCommentForm.tsx index 9049a73d8..90284bdf6 100644 --- a/src/core/client/stream/tabs/Comments/Comment/EditCommentForm/EditCommentForm.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/EditCommentForm/EditCommentForm.tsx @@ -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"; diff --git a/src/core/client/stream/tabs/Comments/Comment/EditCommentForm/EditCommentMutation.ts b/src/core/client/stream/tabs/Comments/Comment/EditCommentForm/EditCommentMutation.ts index 3fccbd59d..1cb9f9af5 100644 --- a/src/core/client/stream/tabs/Comments/Comment/EditCommentForm/EditCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/EditCommentForm/EditCommentMutation.ts @@ -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; @@ -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(environment, { - mutation, - variables: { - input: { - ...pick(input, ["commentID", "body"]), - clientMutationId: clientMutationId.toString(), - }, - }, - optimisticResponse: { - editComment: { - comment: { - id: input.commentID, - body: input.body, - status: lookup(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( + 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(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( diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ApproveCommentMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ApproveCommentMutation.ts index 7c8690f61..9aa6f377d 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ApproveCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ApproveCommentMutation.ts @@ -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) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation ApproveCommentMutation($input: ApproveCommentInput!) { - approveComment(input: $input) { - comment { - status + async ( + environment: Environment, + input: MutationInput, + { eventEmitter }: CoralContext + ) => { + const approveCommentEvent = ApproveCommentEvent.begin(eventEmitter, { + commentID: input.commentID, + }); + try { + const result = await commitMutationPromiseNormalized( + 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; diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/FeatureCommentMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/FeatureCommentMutation.ts index 751176fce..8fef504ff 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/FeatureCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/FeatureCommentMutation.ts @@ -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 & { storyID: string }, - { uuidGenerator }: CoralContext - ) => - commitMutationPromiseNormalized(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( + 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; diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.tsx index 5d58223fa..5b6e87fa5 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionsContainer.tsx @@ -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 = ({ 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 = ({ 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 = ({ className={CLASSES.moderationDropdown.goToModerateButton} href={`/admin/moderate/comment/${comment.id}`} target="_blank" + onClick={onGotoModerate} anchor > Go to Moderate diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx index 53a5aa033..cb94d36f1 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationDropdownContainer.tsx @@ -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 = ({ onDismiss, scheduleUpdate, }) => { + const emitShowEvent = useViewerEvent(ShowModerationPopoverEvent); const [view, setView] = useState("MODERATE"); const onBan = useCallback(() => { setView("BAN"); scheduleUpdate(); }, [setView, scheduleUpdate]); + // run once. + useEffect(() => { + emitShowEvent({ commentID: comment.id }); + }, []); + return (
{view === "MODERATE" ? ( diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts index 3f7822ad3..55fb009f4 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts @@ -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 & { storyID: string } - ) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation RejectCommentMutation($input: RejectCommentInput!) { - rejectComment(input: $input) { - comment { - status - tags { - code - } - story { - commentCounts { + input: MutationInput & { 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( + 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; diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/UnfeatureCommentMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/UnfeatureCommentMutation.ts index 4cc54042b..ca208bf6f 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/UnfeatureCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/UnfeatureCommentMutation.ts @@ -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 & { storyID: string } - ) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation UnfeatureCommentMutation($input: UnfeatureCommentInput!) { - unfeatureComment(input: $input) { - comment { - tags { - code + input: MutationInput & { storyID: string }, + { eventEmitter }: CoralContext + ) => { + const unfeaturedCommentEvent = UnfeatureCommentEvent.begin(eventEmitter, { + commentID: input.commentID, + }); + try { + const result = await commitMutationPromiseNormalized( + 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; diff --git a/src/core/client/stream/tabs/Comments/Comment/PermalinkButton/PermalinkButton.tsx b/src/core/client/stream/tabs/Comments/Comment/PermalinkButton/PermalinkButton.tsx index a703aa9a9..027b67247 100644 --- a/src/core/client/stream/tabs/Comments/Comment/PermalinkButton/PermalinkButton.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/PermalinkButton/PermalinkButton.tsx @@ -34,7 +34,7 @@ const Permalink: FunctionComponent = ({ classes={{ popover: styles.popover }} body={({ toggleVisibility }) => ( - + )} > diff --git a/src/core/client/stream/tabs/Comments/Comment/PermalinkButton/PermalinkPopover.tsx b/src/core/client/stream/tabs/Comments/Comment/PermalinkButton/PermalinkPopover.tsx index b9cd63e09..9151b460a 100644 --- a/src/core/client/stream/tabs/Comments/Comment/PermalinkButton/PermalinkPopover.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/PermalinkButton/PermalinkPopover.tsx @@ -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 { - public render() { - const { permalinkURL } = this.props; - return ( - - - - - ); - } -} +const PermalinkPopover: FunctionComponent = ({ + permalinkURL, + commentID, +}) => { + const emitShowEvent = useViewerEvent(ShowSharePopoverEvent); + const emitCopyEvent = useViewerEvent(CopyPermalinkEvent); + const onButtonClick = useCallback(() => emitCopyEvent({ commentID }), [ + emitCopyEvent, + commentID, + ]); + // Run once. + useEffect(() => { + emitShowEvent({ commentID }); + }, []); + return ( + + + + + ); +}; export default PermalinkPopover; diff --git a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/CreateCommentReactionMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/CreateCommentReactionMutation.ts index de9c512a3..45abc15e8 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/CreateCommentReactionMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/CreateCommentReactionMutation.ts @@ -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; @@ -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 +) { const source = environment.getStore().getSource(); const currentCount = source.get( source.get(source.get(input.commentID)!.actionCounts.__ref)!.reaction.__ref )!.total; - return commitMutationPromiseNormalized(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( + 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(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(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( diff --git a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/RemoveCommentReactionMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/RemoveCommentReactionMutation.ts index bb4c84d6d..02ff0d47d 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/RemoveCommentReactionMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/RemoveCommentReactionMutation.ts @@ -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 +) { const source = environment.getStore().getSource(); const currentCount = source.get( source.get(source.get(input.commentID)!.actionCounts.__ref)!.reaction.__ref )!.total; - return commitMutationPromiseNormalized(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( + 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(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(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( diff --git a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts index ebf41514c..cd18f0d5b 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts @@ -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(environment, input.parentID)!; const viewer = getViewer(environment)!; @@ -162,82 +163,100 @@ function commit( !roleIsAtLeast(viewer.role, GQLUSER_ROLE.STAFF) && storySettings.moderation === "PRE"; - return commitMutationPromiseNormalized(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( + 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( diff --git a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/ReplyCommentForm.tsx b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/ReplyCommentForm.tsx index 863f97898..b9163f2b0 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/ReplyCommentForm.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/ReplyCommentForm.tsx @@ -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 = props => { const inputID = `comments-replyCommentForm-rte-${props.id}`; + const emitFocusEvent = useViewerEvent(ReplyCommentFocusEvent); + const onFocus = useCallback(() => { + emitFocusEvent(); + }, [emitFocusEvent]); return (
{({ handleSubmit, submitting, form, submitError }) => ( @@ -78,6 +90,7 @@ const ReplyCommentForm: FunctionComponent = props => { > input.onChange(cleanupRTEEmptyHTML(html)) } diff --git a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/CreateCommentDontAgreeMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/CreateCommentDontAgreeMutation.ts index 7356e9d47..cc7342fa7 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/CreateCommentDontAgreeMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/CreateCommentDontAgreeMutation.ts @@ -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(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( + 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( diff --git a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/CreateCommentFlagMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/CreateCommentFlagMutation.ts index 258279461..822a71b4c 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/CreateCommentFlagMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/CreateCommentFlagMutation.ts @@ -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(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( + 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( diff --git a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportCommentFormContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportCommentFormContainer.tsx index bc24ce45d..cd8e02c4b 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportCommentFormContainer.tsx @@ -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 { 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 { } } -const enhanced = withCreateCommentDontAgreeMutation( - withCreateCommentFlagMutation( - withFragmentContainer({ - comment: graphql` - fragment ReportCommentFormContainer_comment on Comment { - id - revision { +const enhanced = withContext(({ eventEmitter }) => ({ + eventEmitter, +}))( + withCreateCommentDontAgreeMutation( + withCreateCommentFlagMutation( + withFragmentContainer({ + comment: graphql` + fragment ReportCommentFormContainer_comment on Comment { id + revision { + id + } } - } - `, - })(ReportCommentFormContainer) + `, + })(ReportCommentFormContainer) + ) ) ); export type ReportCommentFormContainerProps = PropTypesOf; diff --git a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportPopover.tsx b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportPopover.tsx index 23eb93950..590dfe647 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportPopover.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportPopover.tsx @@ -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 { - public render() { - const { onClose, onResize, comment } = this.props; - return ( -
- - close - - -
- ); - } -} +const ReportPopover: FunctionComponent = ({ + onClose, + onResize, + comment, +}) => { + return ( +
+ + close + + +
+ ); +}; export default ReportPopover; diff --git a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/__snapshots__/ReportPopover.spec.tsx.snap b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/__snapshots__/ReportPopover.spec.tsx.snap index 6d2e5bbc4..b66256282 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/__snapshots__/ReportPopover.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/__snapshots__/ReportPopover.spec.tsx.snap @@ -13,7 +13,7 @@ exports[`renders correctly 1`] = ` close - ) => { - return commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation BanUserMutation($input: BanUserInput!) { - banUser(input: $input) { - user { - id - status { - ban { - active + async ( + environment: Environment, + input: MutationInput & { commentID: string }, + { eventEmitter }: CoralContext + ) => { + const banUserEvent = BanUserEvent.begin(eventEmitter, { + commentID: input.commentID, + userID: input.userID, + }); + try { + const result = await commitMutationPromiseNormalized( + 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; + } } ); diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx index 350a5bc82..232d5aca9 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx @@ -37,6 +37,7 @@ const UserBanPopoverContainer: FunctionComponent = ({ const onBan = useCallback(() => { banUser({ userID: user.id, + commentID: comment.id, message: getMessage( localeBundles, "common-banEmailTemplate", @@ -48,7 +49,7 @@ const UserBanPopoverContainer: FunctionComponent = ({ reject({ commentID: comment.id, commentRevisionID: comment.revision.id, - storyID: story.id, + noEmit: true, }); } onDismiss(); diff --git a/src/core/client/stream/tabs/Comments/Comment/UserIgnorePopover/IgnoreUserMutation.ts b/src/core/client/stream/tabs/Comments/Comment/UserIgnorePopover/IgnoreUserMutation.ts index 03e06050d..0871ca0c0 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserIgnorePopover/IgnoreUserMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/UserIgnorePopover/IgnoreUserMutation.ts @@ -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) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation IgnoreUserMutation($input: IgnoreUserInput!) { - ignoreUser(input: $input) { - clientMutationId - } + async ( + environment: Environment, + input: MutationInput, + { eventEmitter }: CoralContext + ) => { + const ignoreUserEvent = IgnoreUserEvent.begin(eventEmitter, { + userID: input.userID, + }); + try { + const result = await commitMutationPromiseNormalized( + 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; diff --git a/src/core/client/stream/tabs/Comments/Comment/UserPopover/UserPopoverContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/UserPopover/UserPopoverContainer.tsx index 372d72afb..cb2bf5f91 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserPopover/UserPopoverContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/UserPopover/UserPopoverContainer.tsx @@ -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 = ({ viewer, onDismiss, }) => { + const emitShowUserPopover = useViewerEvent(ShowUserPopoverEvent); + useEffect(() => { + emitShowUserPopover({ userID: user.id }); + }, []); const [view, setView] = useState("OVERVIEW"); const onIgnore = useCallback(() => setView("IGNORE"), [setView]); return ( @@ -47,6 +58,7 @@ const enhanced = withFragmentContainer({ `, user: graphql` fragment UserPopoverContainer_user on User { + id ...UserPopoverOverviewContainer_user ...UserIgnorePopoverContainer_user } diff --git a/src/core/client/stream/tabs/Comments/Comment/__snapshots__/Comment.spec.tsx.snap b/src/core/client/stream/tabs/Comments/Comment/__snapshots__/Comment.spec.tsx.snap index cabee4a1a..5ab9b87a0 100644 --- a/src/core/client/stream/tabs/Comments/Comment/__snapshots__/Comment.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/Comment/__snapshots__/Comment.spec.tsx.snap @@ -24,12 +24,11 @@ exports[`renders username and body 1`] = ` direction="row" itemGutter={true} > - 1995-12-17T03:24:00.000Z - + diff --git a/src/core/client/stream/tabs/Comments/PermalinkView/ConversationThreadContainer.tsx b/src/core/client/stream/tabs/Comments/PermalinkView/ConversationThreadContainer.tsx index ee4a0fa35..dffe311bf 100644 --- a/src/core/client/stream/tabs/Comments/PermalinkView/ConversationThreadContainer.tsx +++ b/src/core/client/stream/tabs/Comments/PermalinkView/ConversationThreadContainer.tsx @@ -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 = ({ + 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 ( +
+ +
); - }; - - 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 ( -
+ } + return ( +
+ }> + {rootParent && ( + + + } + /> + + )} + {remaining > 0 && ( + + + + + + {remaining > 1 && {remaining}} + + + )} + + + {parents.map((parent, i) => ( + 0}> + + {viewer && ( + + )} + + ))} + -
- ); - } - return ( -
- }> - {rootParent && ( - - - } - /> - - )} - {remaining > 0 && ( - - - - - - {remaining > 1 && {remaining}} - - - )} - - - {parents.map((parent, i) => ( - 0}> - - {viewer && ( - - )} - - ))} - - - - -
- ); - } -} + + +
+ ); +}; // TODO: (cvle) This should be autogenerated. interface FragmentVariables { @@ -179,7 +177,7 @@ const enhanced = withContext(ctx => ({ }))( withSetCommentIDMutation( withPaginationContainer< - ConversationThreadContainerProps, + Props, ConversationThreadContainerPaginationQueryVariables, FragmentVariables >( diff --git a/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkViewContainer.tsx b/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkViewContainer.tsx index e1ff2e701..577116408 100644 --- a/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkViewContainer.tsx +++ b/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkViewContainer.tsx @@ -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) => { + 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({ diff --git a/src/core/client/stream/tabs/Comments/PermalinkView/__snapshots__/PermalinkView.spec.tsx.snap b/src/core/client/stream/tabs/Comments/PermalinkView/__snapshots__/PermalinkView.spec.tsx.snap index f896f7c62..e49d07f3d 100644 --- a/src/core/client/stream/tabs/Comments/PermalinkView/__snapshots__/PermalinkView.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/PermalinkView/__snapshots__/PermalinkView.spec.tsx.snap @@ -5,7 +5,7 @@ exports[`renders comment not found 1`] = ` className="PermalinkView-root coral coral-permalink coral-authenticated" size="double" > - @@ -67,7 +67,7 @@ exports[`renders correctly 1`] = ` className="PermalinkView-root coral coral-permalink coral-authenticated" size="double" > - diff --git a/src/core/client/stream/tabs/Comments/RTE/RTE.tsx b/src/core/client/stream/tabs/Comments/RTE/RTE.tsx index 7b1489249..d5e31e1ba 100644 --- a/src/core/client/stream/tabs/Comments/RTE/RTE.tsx +++ b/src/core/client/stream/tabs/Comments/RTE/RTE.tsx @@ -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; + onBlur?: EventHandler; disabled?: boolean; @@ -109,6 +111,8 @@ const RTE: FunctionComponent = props => { contentClassName, placeholderClassName, toolbarClassName, + onFocus, + onBlur, ...rest } = props; return ( @@ -138,6 +142,8 @@ const RTE: FunctionComponent = props => { features={features} ref={forwardRef} toolbarPosition="bottom" + onBlur={onBlur} + onFocus={onFocus} {...rest} />
diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx index fdb59acab..78bc21142 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx @@ -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 = { story: { diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx index 578439db4..cc9e727ed 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx @@ -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 => { 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 => { comments={comments} story={props.story} settings={props.settings} - onShowAll={showAll} + onShowAll={showAllAndEmit} hasMore={props.relay.hasMore()} disableShowAll={isLoadingShowAll} indentLevel={props.indentLevel} diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListViewNewMutation.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListViewNewMutation.tsx index de23a3453..fbdb19a4d 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListViewNewMutation.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListViewNewMutation.tsx @@ -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, + }); }); } ); diff --git a/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyList.spec.tsx.snap b/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyList.spec.tsx.snap index d36eaf648..26fdbee6c 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyList.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyList.spec.tsx.snap @@ -21,7 +21,7 @@ exports[`renders correctly 1`] = ` - - - - - - = 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.relay.hasMore() && ( - - - - )} - - - - )} - - -); + {props.max && ( + + )} + + )} + + + + + + + + )} + + + + )} + + + ); +}; export default PostCommentForm; diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormFake.tsx b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormFake.tsx index 234c6858a..b0308f8b4 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormFake.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormFake.tsx @@ -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 => { + 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 => { placeholder="Post a comment" value={props.draft} onChange={onChange} + onFocus={onFocus} /> diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/__snapshots__/PostCommentFormFake.spec.tsx.snap b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/__snapshots__/PostCommentFormFake.spec.tsx.snap index 1e1ed46ec..439d48375 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/__snapshots__/PostCommentFormFake.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/__snapshots__/PostCommentFormFake.spec.tsx.snap @@ -24,6 +24,7 @@ exports[`renders correctly 1`] = ` > diff --git a/src/core/client/stream/tabs/Comments/Stream/SortMenu.tsx b/src/core/client/stream/tabs/Comments/Stream/SortMenu.tsx index 1a21d16a8..7fc5acb8e 100644 --- a/src/core/client/stream/tabs/Comments/Stream/SortMenu.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/SortMenu.tsx @@ -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 => ( - - {matches => ( - - {!matches && ( - - } - > - Sort By - - - )} - sort) || undefined} - classes={{ - select: (matches && styles.mobileSelect) || undefined, - afterWrapper: (matches && styles.mobileAfterWrapper) || undefined, - }} +const SortMenu: FunctionComponent = props => { + const emitOpenSortMenuEvent = useViewerEvent(OpenSortMenuEvent); + const onClickSelectField = useCallback(() => emitOpenSortMenuEvent(), [ + emitOpenSortMenuEvent, + ]); + return ( + + {matches => ( + - - - - - - - - - - - - - )} - -); + {!matches && ( + + } + > + Sort By + + + )} + sort) || undefined} + classes={{ + select: (matches && styles.mobileSelect) || undefined, + afterWrapper: (matches && styles.mobileAfterWrapper) || undefined, + }} + > + + + + + + + + + + + + + )} + + ); +}; export default SortMenu; diff --git a/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx index 1474ecc41..b8a9252f0 100644 --- a/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx @@ -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> = ({ ); export const StreamContainer: FunctionComponent = props => { + const emitSetCommentsTabEvent = useViewerEvent(SetCommentsTabEvent); + const emitSetCommentsOrderByEvent = useViewerEvent(SetCommentsOrderByEvent); const [local, setLocal] = useLocal( graphql` fragment StreamContainerLocal on Local { @@ -80,13 +87,26 @@ export const StreamContainer: FunctionComponent = props => { ` ); const onChangeOrder = useCallback( - (order: React.ChangeEvent) => - setLocal({ commentsOrderBy: order.target.value as any }), - [setLocal] + (order: React.ChangeEvent) => { + 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 => { // 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]); diff --git a/src/core/client/stream/tabs/Comments/Stream/__snapshots__/SortMenu.spec.tsx.snap b/src/core/client/stream/tabs/Comments/Stream/__snapshots__/SortMenu.spec.tsx.snap index 94ef1456c..19d7cecea 100644 --- a/src/core/client/stream/tabs/Comments/Stream/__snapshots__/SortMenu.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/Stream/__snapshots__/SortMenu.spec.tsx.snap @@ -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" > diff --git a/src/core/client/stream/tabs/Configure/ConfigureStream/UpdateStorySettingsMutation.ts b/src/core/client/stream/tabs/Configure/ConfigureStream/UpdateStorySettingsMutation.ts index c51611900..c4aaddb52 100644 --- a/src/core/client/stream/tabs/Configure/ConfigureStream/UpdateStorySettingsMutation.ts +++ b/src/core/client/stream/tabs/Configure/ConfigureStream/UpdateStorySettingsMutation.ts @@ -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; @@ -25,16 +27,44 @@ const mutation = graphql` let clientMutationId = 0; -function commit(environment: Environment, input: UpdateStorySettingsInput) { - return commitMutationPromiseNormalized(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( + 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( diff --git a/src/core/client/stream/tabs/Configure/OpenOrCloseStream/CloseStoryMutation.ts b/src/core/client/stream/tabs/Configure/OpenOrCloseStream/CloseStoryMutation.ts index 6fcbb569b..1bae88e08 100644 --- a/src/core/client/stream/tabs/Configure/OpenOrCloseStream/CloseStoryMutation.ts +++ b/src/core/client/stream/tabs/Configure/OpenOrCloseStream/CloseStoryMutation.ts @@ -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(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( + 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( diff --git a/src/core/client/stream/tabs/Configure/OpenOrCloseStream/OpenStoryMutation.ts b/src/core/client/stream/tabs/Configure/OpenOrCloseStream/OpenStoryMutation.ts index ea60bd7cf..b5c0503ec 100644 --- a/src/core/client/stream/tabs/Configure/OpenOrCloseStream/OpenStoryMutation.ts +++ b/src/core/client/stream/tabs/Configure/OpenOrCloseStream/OpenStoryMutation.ts @@ -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(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( + 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( diff --git a/src/core/client/stream/tabs/Profile/CommentHistory/CommentHistoryContainer.tsx b/src/core/client/stream/tabs/Profile/CommentHistory/CommentHistoryContainer.tsx index e0e54b888..83e7ae7f2 100644 --- a/src/core/client/stream/tabs/Profile/CommentHistory/CommentHistoryContainer.tsx +++ b/src/core/client/stream/tabs/Profile/CommentHistory/CommentHistoryContainer.tsx @@ -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 ( - - ); - } - - private loadMore = () => { - if (!this.props.relay.hasMore() || this.props.relay.isLoading()) { - return; +export const CommentHistoryContainer: FunctionComponent = 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 ( + + ); +}; // TODO: (cvle) If this could be autogenerated. type FragmentVariables = CommentHistoryContainerPaginationQueryVariables; const enhanced = withPaginationContainer< - CommentHistoryContainerProps, + Props, CommentHistoryContainerPaginationQueryVariables, FragmentVariables >( diff --git a/src/core/client/stream/tabs/Profile/CommentHistory/HistoryComment.tsx b/src/core/client/stream/tabs/Profile/CommentHistory/HistoryComment.tsx index 9a87cff9d..54bc187c6 100644 --- a/src/core/client/stream/tabs/Profile/CommentHistory/HistoryComment.tsx +++ b/src/core/client/stream/tabs/Profile/CommentHistory/HistoryComment.tsx @@ -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"; diff --git a/src/core/client/stream/tabs/Profile/CommentHistory/HistoryCommentContainer.tsx b/src/core/client/stream/tabs/Profile/CommentHistory/HistoryCommentContainer.tsx index ed3ab69a9..d3e74f740 100644 --- a/src/core/client/stream/tabs/Profile/CommentHistory/HistoryCommentContainer.tsx +++ b/src/core/client/stream/tabs/Profile/CommentHistory/HistoryCommentContainer.tsx @@ -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 ( - - ); - } -} +const HistoryCommentContainer: FunctionComponent = 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 ( + + ); +}; const enhanced = withSetCommentIDMutation( - withFragmentContainer({ + withFragmentContainer({ story: graphql` fragment HistoryCommentContainer_story on Story { id diff --git a/src/core/client/stream/tabs/Profile/CommentHistory/__snapshots__/HistoryComment.spec.tsx.snap b/src/core/client/stream/tabs/Profile/CommentHistory/__snapshots__/HistoryComment.spec.tsx.snap index b87f91034..8cebbd2c5 100644 --- a/src/core/client/stream/tabs/Profile/CommentHistory/__snapshots__/HistoryComment.spec.tsx.snap +++ b/src/core/client/stream/tabs/Profile/CommentHistory/__snapshots__/HistoryComment.spec.tsx.snap @@ -25,12 +25,11 @@ exports[`renders correctly 1`] = `
- 2018-07-06T18:24:00.000Z - + = props => { + const emitSetProfileTabEvent = useViewerEvent(SetProfileTabEvent); const [local, setLocal] = useLocal(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 ( diff --git a/src/core/client/stream/tabs/Profile/Settings/ChangeEmail/ChangeEmailContainer.tsx b/src/core/client/stream/tabs/Profile/Settings/ChangeEmail/ChangeEmailContainer.tsx index 10ab40368..17ea23c9b 100644 --- a/src/core/client/stream/tabs/Profile/Settings/ChangeEmail/ChangeEmailContainer.tsx +++ b/src/core/client/stream/tabs/Profile/Settings/ChangeEmail/ChangeEmailContainer.tsx @@ -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("/account/confirm", { - method: "POST", - }); + async (environment: Environment, variables, { eventEmitter, rest }) => { + const resendEmailVerificationEvent = ResendEmailVerificationEvent.begin( + eventEmitter + ); + try { + const result = await rest.fetch("/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 = ({ viewer, settings, }) => { + const emitShowEvent = useViewerEvent(ShowEditEmailDialogEvent); const updateEmail = useMutation(UpdateEmailMutation); const [showEditForm, setShowEditForm] = useState(false); @@ -82,6 +101,9 @@ const changeEmailContainer: FunctionComponent = ({ }, [fetcher]); const toggleEditForm = useCallback(() => { + if (!showEditForm) { + emitShowEvent(); + } setShowEditForm(!showEditForm); }, [setShowEditForm, showEditForm]); const onSubmit = useCallback( diff --git a/src/core/client/stream/tabs/Profile/Settings/ChangeEmail/UpdateEmailMutation.ts b/src/core/client/stream/tabs/Profile/Settings/ChangeEmail/UpdateEmailMutation.ts index dfd4a7e0a..d7f4ef3da 100644 --- a/src/core/client/stream/tabs/Profile/Settings/ChangeEmail/UpdateEmailMutation.ts +++ b/src/core/client/stream/tabs/Profile/Settings/ChangeEmail/UpdateEmailMutation.ts @@ -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) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation UpdateEmailMutation($input: UpdateEmailInput!) { - updateEmail(input: $input) { - clientMutationId - user { - id - email - emailVerified + async ( + environment: Environment, + input: MutationInput, + { eventEmitter } + ) => { + const changeEmailEvent = ChangeEmailEvent.begin(eventEmitter, { + oldEmail: getViewer(environment)!.email!, + newEmail: input.email, + }); + try { + const result = await commitMutationPromiseNormalized( + 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; diff --git a/src/core/client/stream/tabs/Profile/Settings/ChangePassword.tsx b/src/core/client/stream/tabs/Profile/Settings/ChangePassword.tsx index 58378698a..73c5f5438 100644 --- a/src/core/client/stream/tabs/Profile/Settings/ChangePassword.tsx +++ b/src/core/client/stream/tabs/Profile/Settings/ChangePassword.tsx @@ -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 = ({ onResetPassword }) => { + const emitShowEvent = useViewerEvent(ShowEditPasswordDialogEvent); const updatePassword = useMutation(UpdatePasswordMutation); const onSubmit = useCallback( async (input: FormProps, form: FormApi) => { @@ -64,10 +67,12 @@ const ChangePassword: FunctionComponent = ({ onResetPassword }) => { ); const [showForm, setShowForm] = useState(false); - const toggleForm = useCallback(() => setShowForm(!showForm), [ - showForm, - setShowForm, - ]); + const toggleForm = useCallback(() => { + if (!showForm) { + emitShowEvent(); + } + setShowForm(!showForm); + }, [showForm, setShowForm]); return (
= ({ 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); diff --git a/src/core/client/stream/tabs/Profile/Settings/ChangeUsername/UpdateUsernameMutation.tsx b/src/core/client/stream/tabs/Profile/Settings/ChangeUsername/UpdateUsernameMutation.tsx index 282e6a038..09fce39f1 100644 --- a/src/core/client/stream/tabs/Profile/Settings/ChangeUsername/UpdateUsernameMutation.tsx +++ b/src/core/client/stream/tabs/Profile/Settings/ChangeUsername/UpdateUsernameMutation.tsx @@ -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) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation UpdateUsernameMutation($input: UpdateUsernameInput!) { - updateUsername(input: $input) { - clientMutationId - user { - username - status { - username { - history { - username - createdAt + async ( + environment: Environment, + input: MutationInput, + { eventEmitter } + ) => { + const changeUsernameEvent = ChangeUsernameEvent.begin(eventEmitter, { + oldUsername: getViewer(environment)!.username!, + newUsername: input.username, + }); + try { + const result = await commitMutationPromiseNormalized( + 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; diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/RequestAccountDeletionMutation.tsx b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/RequestAccountDeletionMutation.tsx index 21b37d4e1..9f8873f3c 100644 --- a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/RequestAccountDeletionMutation.tsx +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/RequestAccountDeletionMutation.tsx @@ -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) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation RequestAccountDeletionMutation( - $input: RequestAccountDeletionInput! - ) { - requestAccountDeletion(input: $input) { - user { - scheduledDeletionDate + async ( + environment: Environment, + input: MutationInput, + { eventEmitter } + ) => { + const requestAccountDeletionEvent = RequestAccountDeletionEvent.begin( + eventEmitter + ); + try { + const result = await commitMutationPromiseNormalized( + 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; diff --git a/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.tsx b/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.tsx index b89b04256..5b6778750 100644 --- a/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.tsx +++ b/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.tsx @@ -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 = ({ 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 (
) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation RemoveUserIgnoreMutation($input: RemoveUserIgnoreInput!) { - removeUserIgnore(input: $input) { - clientMutationId - } + async ( + environment: Environment, + input: MutationInput, + { eventEmitter }: CoralContext + ) => { + const removeUserIgnore = RemoveUserIgnoreEvent.begin(eventEmitter, { + userID: input.userID, + }); + try { + const result = await commitMutationPromiseNormalized( + 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; diff --git a/src/core/client/stream/tabs/Profile/Settings/RequestCommentsDownloadMutation.ts b/src/core/client/stream/tabs/Profile/Settings/RequestCommentsDownloadMutation.ts index fc1fc6771..4c2da42e1 100644 --- a/src/core/client/stream/tabs/Profile/Settings/RequestCommentsDownloadMutation.ts +++ b/src/core/client/stream/tabs/Profile/Settings/RequestCommentsDownloadMutation.ts @@ -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) => { + async ( + environment: Environment, + input: MutationInput, + { 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(environment, { - mutation: graphql` - mutation RequestCommentsDownloadMutation( - $input: RequestCommentsDownloadInput! - ) { - requestCommentsDownload(input: $input) { - clientMutationId - } + const requestDownloadCommentHistoryEvent = RequestDownloadCommentHistoryEvent.begin( + eventEmitter + ); + try { + const result = await commitMutationPromiseNormalized( + 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; + } } ); diff --git a/src/core/client/stream/tabs/Profile/Settings/UpdateNotificationSettingsMutation.ts b/src/core/client/stream/tabs/Profile/Settings/UpdateNotificationSettingsMutation.ts index afca329dd..25a23e925 100644 --- a/src/core/client/stream/tabs/Profile/Settings/UpdateNotificationSettingsMutation.ts +++ b/src/core/client/stream/tabs/Profile/Settings/UpdateNotificationSettingsMutation.ts @@ -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) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation UpdateNotificationSettingsMutation( - $input: UpdateNotificationSettingsInput! - ) { - updateNotificationSettings(input: $input) { - user { - id - notifications { - onReply - onFeatured - onStaffReplies - onModeration - digestFrequency + async ( + environment: Environment, + input: MutationInput, + { 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( + 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; diff --git a/src/core/client/stream/tabs/Profile/Settings/UpdatePasswordMutation.tsx b/src/core/client/stream/tabs/Profile/Settings/UpdatePasswordMutation.tsx index d9a8671b7..fba518537 100644 --- a/src/core/client/stream/tabs/Profile/Settings/UpdatePasswordMutation.tsx +++ b/src/core/client/stream/tabs/Profile/Settings/UpdatePasswordMutation.tsx @@ -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) => - commitMutationPromiseNormalized(environment, { - mutation: graphql` - mutation UpdatePasswordMutation($input: UpdatePasswordInput!) { - updatePassword(input: $input) { - clientMutationId - } + async ( + environment: Environment, + input: MutationInput, + { eventEmitter } + ) => { + const changePasswordEvent = ChangePasswordEvent.begin(eventEmitter); + try { + const result = await commitMutationPromiseNormalized( + 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; diff --git a/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap b/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap index 7aa0df686..c6c31dd23 100644 --- a/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap +++ b/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap @@ -86,6 +86,8 @@ exports[`renders comment stream 1`] = `
diff --git a/src/core/client/stream/test/comments/stream/__snapshots__/editComment.spec.tsx.snap b/src/core/client/stream/test/comments/stream/__snapshots__/editComment.spec.tsx.snap index cb06e9432..082d16f60 100644 --- a/src/core/client/stream/test/comments/stream/__snapshots__/editComment.spec.tsx.snap +++ b/src/core/client/stream/test/comments/stream/__snapshots__/editComment.spec.tsx.snap @@ -299,6 +299,8 @@ exports[`edit a comment and handle server error: edit form 1`] = `
diff --git a/src/core/client/stream/test/comments/stream/__snapshots__/renderMessageBox.spec.tsx.snap b/src/core/client/stream/test/comments/stream/__snapshots__/renderMessageBox.spec.tsx.snap index adb258fa0..8847ea661 100644 --- a/src/core/client/stream/test/comments/stream/__snapshots__/renderMessageBox.spec.tsx.snap +++ b/src/core/client/stream/test/comments/stream/__snapshots__/renderMessageBox.spec.tsx.snap @@ -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`] = `
@@ -603,6 +607,8 @@ exports[`renders message box when not logged in 1`] = `
@@ -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" > diff --git a/src/core/client/stream/test/comments/stream/__snapshots__/renderStream.spec.tsx.snap b/src/core/client/stream/test/comments/stream/__snapshots__/renderStream.spec.tsx.snap index 5fc3135f1..40a10878c 100644 --- a/src/core/client/stream/test/comments/stream/__snapshots__/renderStream.spec.tsx.snap +++ b/src/core/client/stream/test/comments/stream/__snapshots__/renderStream.spec.tsx.snap @@ -86,6 +86,8 @@ exports[`renders comment stream 1`] = `
diff --git a/src/core/client/ui/components/SelectField/SelectField.tsx b/src/core/client/ui/components/SelectField/SelectField.tsx index 47e785188..75483a80c 100644 --- a/src/core/client/ui/components/SelectField/SelectField.tsx +++ b/src/core/client/ui/components/SelectField/SelectField.tsx @@ -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>; + onClick?: EventHandler; disabled?: boolean; // These handlers are passed down by the `withKeyboardFocus` HOC. diff --git a/src/core/client/ui/components/Timestamp/Timestamp.tsx b/src/core/client/ui/components/Timestamp/Timestamp.tsx index 151cfc427..3f91ebfd6 100644 --- a/src/core/client/ui/components/Timestamp/Timestamp.tsx +++ b/src/core/client/ui/components/Timestamp/Timestamp.tsx @@ -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; } const Timestamp: FunctionComponent = 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 ( - + {showAbsolute ? (