Merge branch 'next-comment-counts' of github.com:coralproject/talk into next-comment-counts

* 'next-comment-counts' of github.com:coralproject/talk: (53 commits)
  fix: Readd and fix App unittest
  fix: undesired margin
  Correct optimitic updates
  fix: correct typings
  fix: linting
  Optimistic Responses updated
  Adding optimisticresponse for deleteCommentReaction
  Changes
  test: use baseComment and baseAsset in fixtures
  fix: lint
  test: simplify tests
  fix: pass settings to deeper ReplyListContainer
  fix: remove debug
  fix: test updates
  feat: replaced respect with reaction and added some options!
  Adding spected
  More snapshots
  It was the optimistic response
  Adding actionCounts to the fixtures
  Updated snapshots
  ...
This commit is contained in:
Belén Curcio
2018-10-08 16:44:24 -03:00
83 changed files with 3664 additions and 442 deletions
+6 -6
View File
@@ -1647,9 +1647,9 @@
}
},
"@types/bson": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@types/bson/-/bson-1.0.10.tgz",
"integrity": "sha512-gRf+Qy5Qiyjz28ZkPRP37bDHtGG67op/lV2qcIMhWUq4vIMJ6/j13ajeYH7LFhJ5RNflyLHmdANPGDXZ5a8EzQ==",
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@types/bson/-/bson-1.0.11.tgz",
"integrity": "sha512-j+UcCWI+FsbI5/FQP/Kj2CXyplWAz39ktHFkXk84h7dNblKRSoNJs95PZFRd96NQGqsPEPgeclqnznWZr14ZDA==",
"dev": true,
"requires": {
"@types/node": "*"
@@ -1989,9 +1989,9 @@
}
},
"@types/mongodb": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.1.1.tgz",
"integrity": "sha512-lHEH+OwYNeuC28jlmdPT/wBAVMuB6M1sHjZKAtaho/LeJf78ILJPYUq2OD7mWVj9QR7uYiKVt4ExGkXegHFCJQ==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.1.8.tgz",
"integrity": "sha512-5higsHdPx63XKIh5hjr5GGrCCErBqEbpZZiNsUcqk97mMDpCBH9R4dRi/T8bcMrQItCdL+wecagdAj3JPKkuVg==",
"dev": true,
"requires": {
"@types/bson": "*",
+1 -1
View File
@@ -124,7 +124,7 @@
"@types/lodash": "^4.14.111",
"@types/luxon": "^0.5.3",
"@types/mini-css-extract-plugin": "^0.2.0",
"@types/mongodb": "^3.1.1",
"@types/mongodb": "^3.1.8",
"@types/ms": "^0.7.30",
"@types/node": "^10.5.2",
"@types/node-fetch": "^2.1.2",
@@ -0,0 +1,16 @@
import { shallow } from "enzyme";
import noop from "lodash";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import App from "./App";
it("renders comments", () => {
const props: PropTypesOf<typeof App> = {
activeTab: "COMMENTS",
onTabClick: noop,
};
const wrapper = shallow(<App {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders comments 1`] = `
<withPropsOnChange(HorizontalGutter)
className="App-root"
>
<withPropsOnChange(TabBar)
activeTab="COMMENTS"
onTabClick={[Function]}
>
<CommentsTab
tabId="COMMENTS"
/>
<MyProfileTab
tabId="PROFILE"
/>
</withPropsOnChange(TabBar)>
<TabContent
activeTab="COMMENTS"
className="App-tabContent"
>
<TabPane
tabId="COMMENTS"
>
<withContext(withLocalStateContainer(CommentsPaneContainer)) />
</TabPane>
<TabPane
tabId="PROFILE"
>
<withContext(withLocalStateContainer(ProfileQuery)) />
</TabPane>
</TabContent>
</withPropsOnChange(HorizontalGutter)>
`;
@@ -158,6 +158,11 @@ function commit(
editing: {
editableUntil: new Date(Date.now() + 10000),
},
actionCounts: {
reaction: {
total: 0,
},
},
},
},
clientMutationId: (clientMutationId++).toString(),
@@ -0,0 +1,70 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
} from "talk-framework/lib/relay";
import { Omit } from "talk-framework/types";
import { CreateCommentReactionMutation as MutationTypes } from "talk-stream/__generated__/CreateCommentReactionMutation.graphql";
export type CreateCommentReactionInput = Omit<
MutationTypes["variables"]["input"],
"clientMutationId"
>;
const mutation = graphql`
mutation CreateCommentReactionMutation($input: CreateCommentReactionInput!) {
createCommentReaction(input: $input) {
comment {
...ReactionButtonContainer_comment
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: CreateCommentReactionInput) {
const source = environment.getStore().getSource();
const currentCount = source.get(
source.get(source.get(input.commentID)!.actionCounts.__ref)!.reaction.__ref
)!.total;
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
createCommentReaction: {
comment: {
id: input.commentID,
myActionPresence: {
reaction: true,
},
actionCounts: {
reaction: {
total: currentCount + 1,
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
} as any, // TODO: (cvle) generated types should contain one for the optimistic response.
});
}
export const withCreateCommentReactionMutation = createMutationContainer(
"createCommentReaction",
commit
);
export type CreateCommentReactionMutation = (
input: CreateCommentReactionInput
) => Promise<MutationTypes["response"]["createCommentReaction"]>;
@@ -0,0 +1,64 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
} from "talk-framework/lib/relay";
import { DeleteCommentReactionMutation as MutationTypes } from "talk-stream/__generated__/DeleteCommentReactionMutation.graphql";
import { CreateCommentReactionInput } from "./CreateCommentReactionMutation";
const mutation = graphql`
mutation DeleteCommentReactionMutation($input: CreateCommentReactionInput!) {
deleteCommentReaction(input: $input) {
comment {
...ReactionButtonContainer_comment
}
clientMutationId
}
}
`;
const clientMutationId = 0;
function commit(environment: Environment, input: CreateCommentReactionInput) {
const source = environment.getStore().getSource();
const currentCount = source.get(
source.get(source.get(input.commentID)!.actionCounts.__ref)!.reaction.__ref
)!.total;
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
deleteCommentReaction: {
comment: {
id: input.commentID,
myActionPresence: {
reaction: false,
},
actionCounts: {
reaction: {
total: currentCount - 1,
},
},
},
clientMutationId: clientMutationId.toString(),
},
} as any, // TODO: (cvle) generated types should contain one for the optimistic response.
});
}
export const withDeleteCommentReactionMutation = createMutationContainer(
"deleteCommentReaction",
commit
);
export type DeleteCommentReactionMutation = (
input: CreateCommentReactionInput
) => Promise<MutationTypes["response"]["deleteCommentReaction"]>;
@@ -31,3 +31,12 @@ export {
withSetActiveTabMutation,
SetActiveTabMutation,
} from "./SetActiveTabMutation";
export {
withCreateCommentReactionMutation,
CreateCommentReactionMutation,
CreateCommentReactionInput,
} from "./CreateCommentReactionMutation";
export {
withDeleteCommentReactionMutation,
DeleteCommentReactionMutation,
} from "./DeleteCommentReactionMutation";
@@ -35,7 +35,7 @@ class Permalink extends React.Component<PermalinkProps> {
id={popoverID}
placement="top-start"
description="A dialog showing a permalink to the comment"
className={styles.popover}
classes={{ popover: styles.popover }}
body={({ toggleVisibility }) => (
<ClickOutside
onClickOutside={() =>
@@ -10,6 +10,7 @@ import * as styles from "./PermalinkView.css";
export interface PermalinkViewProps {
me: PropTypesOf<typeof CommentContainer>["me"];
asset: PropTypesOf<typeof CommentContainer>["asset"];
settings: PropTypesOf<typeof CommentContainer>["settings"];
comment: PropTypesOf<typeof CommentContainer>["comment"] | null;
showAllCommentsHref: string | null;
onShowAllComments: (e: MouseEvent<any>) => void;
@@ -18,6 +19,7 @@ export interface PermalinkViewProps {
const PermalinkView: StatelessComponent<PermalinkViewProps> = ({
showAllCommentsHref,
comment,
settings,
asset,
onShowAllComments,
me,
@@ -46,7 +48,14 @@ const PermalinkView: StatelessComponent<PermalinkViewProps> = ({
<Typography>Comment not found</Typography>
</Localized>
)}
{comment && <CommentContainer me={me} comment={comment} asset={asset} />}
{comment && (
<CommentContainer
settings={settings}
me={me}
comment={comment}
asset={asset}
/>
)}
</div>
);
};
@@ -0,0 +1,43 @@
import React from "react";
import { Button, ButtonIcon, MatchMedia } from "talk-ui/components";
interface ReactionButtonProps {
onClick: () => void;
totalReactions: number;
reacted: boolean | null;
label: string;
labelActive: string | null;
icon: string;
iconActive: string | null;
// color: string;
}
class ReactionButton extends React.Component<ReactionButtonProps> {
public render() {
const { totalReactions, reacted } = this.props;
return (
<Button variant="ghost" size="small" onClick={this.props.onClick}>
{!!totalReactions && <span>{totalReactions}</span>}
<MatchMedia gtWidth="xs">
<ButtonIcon>
{reacted
? this.props.iconActive
? this.props.iconActive
: this.props.icon
: this.props.icon}
</ButtonIcon>
</MatchMedia>
<span>
{reacted
? this.props.labelActive
? this.props.labelActive
: this.props.label
: this.props.label}
</span>
</Button>
);
}
}
export default ReactionButton;
@@ -25,6 +25,12 @@ it("renders correctly", () => {
me: null,
localReply: false,
disableReplies: false,
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
};
const wrapper = shallow(<ReplyListN {...props} />);
expect(wrapper).toMatchSnapshot();
@@ -40,6 +46,12 @@ describe("when there is more", () => {
disableShowAll: false,
indentLevel: 1,
me: null,
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
};
const wrapper = shallow(<ReplyListN {...props} />);
@@ -15,29 +15,21 @@ export interface ReplyListProps {
id: string;
};
comments: ReadonlyArray<
{ id: string; showConversationLink?: boolean } & PropTypesOf<
typeof CommentContainer
>["comment"]
{
id: string;
replyListElement?: React.ReactElement<any>;
showConversationLink?: boolean;
} & PropTypesOf<typeof CommentContainer>["comment"]
>;
settings: PropTypesOf<typeof CommentContainer>["settings"];
onShowAll?: () => void;
hasMore?: boolean;
disableShowAll?: boolean;
indentLevel?: number;
ReplyListComponent?: React.ComponentType<any>;
localReply?: boolean;
disableReplies?: boolean;
}
function getReplyListElement(
{ ReplyListComponent, me, asset }: ReplyListProps,
comment: PropTypesOf<typeof CommentContainer>["comment"]
) {
if (!ReplyListComponent) {
return null;
}
return <ReplyListComponent me={me} comment={comment} asset={asset} />;
}
const ReplyList: StatelessComponent<ReplyListProps> = props => {
return (
<HorizontalGutter
@@ -51,12 +43,13 @@ const ReplyList: StatelessComponent<ReplyListProps> = props => {
me={props.me}
comment={comment}
asset={props.asset}
settings={props.settings}
indentLevel={props.indentLevel}
localReply={props.localReply}
disableReplies={props.disableReplies}
showConversationLink={!!comment.showConversationLink}
/>
{getReplyListElement(props, comment)}
{comment.replyListElement}
</HorizontalGutter>
))}
{props.hasMore && (
@@ -17,6 +17,12 @@ it("renders correctly", () => {
isClosed: false,
},
comments: [{ id: "comment-1" }, { id: "comment-2" }],
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
onLoadMore: noop,
disableLoadMore: false,
hasMore: false,
@@ -38,6 +44,12 @@ describe("when use is logged in", () => {
disableLoadMore: false,
hasMore: false,
me: {},
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
};
const wrapper = shallow(<StreamN {...props} />);
expect(wrapper).toMatchSnapshot();
@@ -51,6 +63,12 @@ describe("when there is more", () => {
isClosed: false,
},
comments: [{ id: "comment-1" }, { id: "comment-2" }],
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
onLoadMore: sinon.spy(),
disableLoadMore: false,
hasMore: true,
@@ -18,6 +18,8 @@ export interface StreamProps {
isClosed?: boolean;
} & PropTypesOf<typeof CommentContainer>["asset"] &
PropTypesOf<typeof ReplyListContainer>["asset"];
settings: PropTypesOf<typeof CommentContainer>["settings"] &
PropTypesOf<typeof ReplyListContainer>["settings"];
comments: ReadonlyArray<
{ id: string } & PropTypesOf<typeof CommentContainer>["comment"] &
PropTypesOf<typeof ReplyListContainer>["comment"]
@@ -52,10 +54,12 @@ const Stream: StatelessComponent<StreamProps> = props => {
<HorizontalGutter key={comment.id}>
<CommentContainer
me={props.me}
settings={props.settings}
comment={comment}
asset={props.asset}
/>
<ReplyListContainer
settings={props.settings}
me={props.me}
comment={comment}
asset={props.asset}
@@ -24,6 +24,14 @@ exports[`renders correctly 1`] = `
key="comment-1"
localReply={false}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={false}
/>
</withPropsOnChange(HorizontalGutter)>
@@ -47,6 +55,14 @@ exports[`renders correctly 1`] = `
key="comment-2"
localReply={false}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={true}
/>
</withPropsOnChange(HorizontalGutter)>
@@ -75,6 +91,14 @@ exports[`when there is more disables load more button 1`] = `
indentLevel={1}
key="comment-1"
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={false}
/>
</withPropsOnChange(HorizontalGutter)>
@@ -95,6 +119,14 @@ exports[`when there is more disables load more button 1`] = `
indentLevel={1}
key="comment-2"
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={false}
/>
</withPropsOnChange(HorizontalGutter)>
@@ -142,6 +174,14 @@ exports[`when there is more renders a load more button 1`] = `
indentLevel={1}
key="comment-1"
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={false}
/>
</withPropsOnChange(HorizontalGutter)>
@@ -162,6 +202,14 @@ exports[`when there is more renders a load more button 1`] = `
indentLevel={1}
key="comment-2"
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
showConversationLink={false}
/>
</withPropsOnChange(HorizontalGutter)>
@@ -34,6 +34,14 @@ exports[`renders correctly 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
<withProps(Relay(ReplyListContainer))
asset={
@@ -48,6 +56,14 @@ exports[`renders correctly 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
</withPropsOnChange(HorizontalGutter)>
<withPropsOnChange(HorizontalGutter)
@@ -66,6 +82,14 @@ exports[`renders correctly 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
<withProps(Relay(ReplyListContainer))
asset={
@@ -80,6 +104,14 @@ exports[`renders correctly 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
</withPropsOnChange(HorizontalGutter)>
</withPropsOnChange(HorizontalGutter)>
@@ -120,6 +152,14 @@ exports[`when there is more disables load more button 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
<withProps(Relay(ReplyListContainer))
asset={
@@ -134,6 +174,14 @@ exports[`when there is more disables load more button 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
</withPropsOnChange(HorizontalGutter)>
<withPropsOnChange(HorizontalGutter)
@@ -152,6 +200,14 @@ exports[`when there is more disables load more button 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
<withProps(Relay(ReplyListContainer))
asset={
@@ -166,6 +222,14 @@ exports[`when there is more disables load more button 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
</withPropsOnChange(HorizontalGutter)>
<Localized
@@ -220,6 +284,14 @@ exports[`when there is more renders a load more button 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
<withProps(Relay(ReplyListContainer))
asset={
@@ -234,6 +306,14 @@ exports[`when there is more renders a load more button 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
</withPropsOnChange(HorizontalGutter)>
<withPropsOnChange(HorizontalGutter)
@@ -252,6 +332,14 @@ exports[`when there is more renders a load more button 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
<withProps(Relay(ReplyListContainer))
asset={
@@ -266,6 +354,14 @@ exports[`when there is more renders a load more button 1`] = `
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
</withPropsOnChange(HorizontalGutter)>
<Localized
@@ -322,6 +418,14 @@ exports[`when use is logged in renders correctly 1`] = `
}
}
me={Object {}}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
<withProps(Relay(ReplyListContainer))
asset={
@@ -336,6 +440,14 @@ exports[`when use is logged in renders correctly 1`] = `
}
}
me={Object {}}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
</withPropsOnChange(HorizontalGutter)>
<withPropsOnChange(HorizontalGutter)
@@ -354,6 +466,14 @@ exports[`when use is logged in renders correctly 1`] = `
}
}
me={Object {}}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
<withProps(Relay(ReplyListContainer))
asset={
@@ -368,6 +488,14 @@ exports[`when use is logged in renders correctly 1`] = `
}
}
me={Object {}}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
</withPropsOnChange(HorizontalGutter)>
</withPropsOnChange(HorizontalGutter)>
@@ -30,6 +30,12 @@ it("renders username and body", () => {
},
pending: false,
},
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
indentLevel: 1,
showAuthPopup: noop as any,
setCommentID: noop as any,
@@ -61,6 +67,12 @@ it("renders body only", () => {
},
pending: false,
},
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
indentLevel: 1,
showAuthPopup: noop as any,
setCommentID: noop as any,
@@ -90,6 +102,12 @@ it("hide reply button", () => {
},
pending: false,
},
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
indentLevel: 1,
showAuthPopup: noop as any,
setCommentID: noop as any,
@@ -107,6 +125,13 @@ it("shows conversation link", () => {
asset: {
url: "http://localhost/asset",
},
settings: {
reaction: {
icon: "thumb_up",
label: "Respect",
labelActive: "Respected",
},
},
comment: {
id: "comment-id",
author: {
@@ -9,6 +9,7 @@ import { PropTypesOf } from "talk-framework/types";
import { CommentContainer_asset as AssetData } from "talk-stream/__generated__/CommentContainer_asset.graphql";
import { CommentContainer_comment as CommentData } from "talk-stream/__generated__/CommentContainer_comment.graphql";
import { CommentContainer_me as MeData } from "talk-stream/__generated__/CommentContainer_me.graphql";
import { CommentContainer_settings as SettingsData } from "talk-stream/__generated__/CommentContainer_settings.graphql";
import {
SetCommentIDMutation,
ShowAuthPopupMutation,
@@ -16,6 +17,7 @@ import {
withShowAuthPopupMutation,
} from "talk-stream/mutations";
import ReactionButtonContainer from "talk-stream/tabs/comments/containers/ReactionButtonContainer";
import { Button } from "talk-ui/components";
import Comment, {
ButtonsBar,
@@ -30,6 +32,7 @@ interface InnerProps {
me: MeData | null;
comment: CommentData;
asset: AssetData;
settings: SettingsData;
indentLevel?: number;
showAuthPopup: ShowAuthPopupMutation;
setCommentID: SetCommentIDMutation;
@@ -131,6 +134,7 @@ export class CommentContainer extends Component<InnerProps, State> {
public render() {
const {
comment,
settings,
asset,
indentLevel,
localReply,
@@ -182,6 +186,12 @@ export class CommentContainer extends Component<InnerProps, State> {
/>
)}
<PermalinkButtonContainer commentID={comment.id} />
{this.props.me && (
<ReactionButtonContainer
comment={comment}
settings={settings}
/>
)}
</ButtonsBar>
{showConversationLink && (
<ShowConversationLink
@@ -241,6 +251,12 @@ const enhanced = withSetCommentIDMutation(
pending
...ReplyCommentFormContainer_comment
...EditCommentFormContainer_comment
...ReactionButtonContainer_comment
}
`,
settings: graphql`
fragment CommentContainer_settings on Settings {
...ReactionButtonContainer_settings
}
`,
})(CommentContainer)
@@ -6,6 +6,7 @@ import { PropTypesOf } from "talk-framework/types";
import { LocalReplyListContainer_asset as AssetData } from "talk-stream/__generated__/LocalReplyListContainer_asset.graphql";
import { LocalReplyListContainer_comment as CommentData } from "talk-stream/__generated__/LocalReplyListContainer_comment.graphql";
import { LocalReplyListContainer_me as MeData } from "talk-stream/__generated__/LocalReplyListContainer_me.graphql";
import { LocalReplyListContainer_settings as SettingsData } from "talk-stream/__generated__/LocalReplyListContainer_settings.graphql";
import ReplyList from "../components/ReplyList";
@@ -14,6 +15,7 @@ interface InnerProps {
me: MeData;
asset: AssetData;
comment: CommentData;
settings: SettingsData;
}
/**
@@ -30,6 +32,7 @@ export class LocalReplyListContainer extends Component<InnerProps> {
return (
<ReplyList
me={this.props.me}
settings={this.props.settings}
comment={this.props.comment}
comments={this.props.comment.localReplies}
asset={this.props.asset}
@@ -60,6 +63,11 @@ const enhanced = withFragmentContainer<InnerProps>({
}
}
`,
settings: graphql`
fragment LocalReplyListContainer_settings on Settings {
...CommentContainer_settings
}
`,
})(LocalReplyListContainer);
export type LocalReplyListContainerProps = PropTypesOf<typeof enhanced>;
@@ -8,6 +8,7 @@ import { withFragmentContainer } from "talk-framework/lib/relay";
import { PermalinkViewContainer_asset as AssetData } from "talk-stream/__generated__/PermalinkViewContainer_asset.graphql";
import { PermalinkViewContainer_comment as CommentData } from "talk-stream/__generated__/PermalinkViewContainer_comment.graphql";
import { PermalinkViewContainer_me as MeData } from "talk-stream/__generated__/PermalinkViewContainer_me.graphql";
import { PermalinkViewContainer_settings as SettingsData } from "talk-stream/__generated__/PermalinkViewContainer_settings.graphql";
import {
SetCommentIDMutation,
withSetCommentIDMutation,
@@ -18,6 +19,7 @@ import PermalinkView from "../components/PermalinkView";
interface PermalinkViewContainerProps {
comment: CommentData | null;
asset: AssetData;
settings: SettingsData;
me: MeData | null;
setCommentID: SetCommentIDMutation;
pym: PymChild | undefined;
@@ -48,12 +50,13 @@ class PermalinkViewContainer extends React.Component<
}
public render() {
const { comment, asset, me } = this.props;
const { comment, asset, me, settings } = this.props;
return (
<PermalinkView
me={me}
asset={asset}
comment={comment}
settings={settings}
showAllCommentsHref={this.getShowAllCommentsHref()}
onShowAllComments={this.showAllComments}
/>
@@ -82,6 +85,11 @@ const enhanced = withContext(ctx => ({
...CommentContainer_me
}
`,
settings: graphql`
fragment PermalinkViewContainer_settings on Settings {
...CommentContainer_settings
}
`,
})(PermalinkViewContainer)
)
);
@@ -0,0 +1,93 @@
import React from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { ReactionButtonContainer_comment as CommentData } from "talk-stream/__generated__/ReactionButtonContainer_comment.graphql";
import { ReactionButtonContainer_settings as SettingsData } from "talk-stream/__generated__/ReactionButtonContainer_settings.graphql";
import {
CreateCommentReactionMutation,
DeleteCommentReactionMutation,
withCreateCommentReactionMutation,
withDeleteCommentReactionMutation,
} from "talk-stream/mutations";
import ReactionButton from "talk-stream/tabs/comments/components/ReactionButton";
interface ReactionButtonContainerProps {
createCommentReaction: CreateCommentReactionMutation;
deleteCommentReaction: DeleteCommentReactionMutation;
comment: CommentData;
settings: SettingsData;
}
class ReactionButtonContainer extends React.Component<
ReactionButtonContainerProps
> {
private handleClick = () => {
const input = {
commentID: this.props.comment.id,
};
const { createCommentReaction, deleteCommentReaction } = this.props;
const reacted =
this.props.comment.myActionPresence &&
this.props.comment.myActionPresence.reaction;
reacted ? deleteCommentReaction(input) : createCommentReaction(input);
};
public render() {
const {
actionCounts: {
reaction: { total: totalReactions },
},
} = this.props.comment;
const {
reaction: { label, labelActive, icon, iconActive },
} = this.props.settings;
const reacted =
this.props.comment.myActionPresence &&
this.props.comment.myActionPresence.reaction;
return (
<ReactionButton
onClick={this.handleClick}
totalReactions={totalReactions}
reacted={reacted}
label={label}
labelActive={labelActive}
icon={icon}
iconActive={iconActive}
/>
);
}
}
export default withDeleteCommentReactionMutation(
withCreateCommentReactionMutation(
withFragmentContainer<ReactionButtonContainerProps>({
comment: graphql`
fragment ReactionButtonContainer_comment on Comment {
id
myActionPresence {
reaction
}
actionCounts {
reaction {
total
}
}
}
`,
settings: graphql`
fragment ReactionButtonContainer_settings on Settings {
reaction {
label
labelActive
icon
iconActive
}
}
`,
})(ReactionButtonContainer)
)
);
@@ -22,6 +22,12 @@ it("renders correctly", () => {
edges: [{ node: { id: "comment-1" } }, { node: { id: "comment-2" } }],
},
},
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
relay: {
hasMore: noop,
isLoading: noop,
@@ -49,6 +55,12 @@ it("renders correctly when replies are empty", () => {
isLoading: noop,
} as any,
me: null,
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
indentLevel: 1,
ReplyListComponent: undefined,
localReply: false,
@@ -69,6 +81,12 @@ describe("when has more replies", () => {
edges: [{ node: { id: "comment-1" } }, { node: { id: "comment-2" } }],
},
},
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
relay: {
hasMore: () => true,
isLoading: () => false,
@@ -7,6 +7,7 @@ import { PropTypesOf } from "talk-framework/types";
import { ReplyListContainer1_asset as AssetData } from "talk-stream/__generated__/ReplyListContainer1_asset.graphql";
import { ReplyListContainer1_comment as CommentData } from "talk-stream/__generated__/ReplyListContainer1_comment.graphql";
import { ReplyListContainer1_me as MeData } from "talk-stream/__generated__/ReplyListContainer1_me.graphql";
import { ReplyListContainer1_settings as SettingsData } from "talk-stream/__generated__/ReplyListContainer1_settings.graphql";
import {
COMMENT_SORT,
ReplyListContainer1PaginationQueryVariables,
@@ -14,22 +15,29 @@ import {
import { ReplyListContainer5_comment as Comment5Data } from "talk-stream/__generated__/ReplyListContainer5_comment.graphql";
import { StatelessComponent } from "enzyme";
import { FragmentKeys } from "talk-framework/lib/relay/types";
import ReplyList from "../components/ReplyList";
import LocalReplyListContainer from "./LocalReplyListContainer";
type UnpackArray<T> = T extends ReadonlyArray<infer U> ? U : any;
type ReplyNode5 = UnpackArray<Comment5Data["replies"]["edges"]>["node"];
export interface InnerProps {
export interface BaseProps {
me: MeData | null;
asset: AssetData;
comment: CommentData;
settings: SettingsData;
relay: RelayPaginationProp;
indentLevel: number;
ReplyListComponent: React.ComponentType<any> | undefined;
localReply: boolean | undefined;
}
export type InnerProps = BaseProps & {
ReplyListComponent:
| React.ComponentType<{ [P in FragmentKeys<BaseProps>]: any }>
| undefined;
};
// TODO: (cvle) This should be autogenerated.
interface FragmentVariables {
count: number;
@@ -50,6 +58,14 @@ export class ReplyListContainer extends React.Component<InnerProps> {
}
const comments = this.props.comment.replies.edges.map(edge => ({
...edge.node,
replyListElement: this.props.ReplyListComponent && (
<this.props.ReplyListComponent
me={this.props.me}
comment={edge.node}
asset={this.props.asset}
settings={this.props.settings}
/>
),
// ReplyListContainer5 contains replyCount.
showConversationLink: ((edge.node as any) as ReplyNode5).replyCount > 0,
}));
@@ -59,11 +75,11 @@ export class ReplyListContainer extends React.Component<InnerProps> {
comment={this.props.comment}
comments={comments}
asset={this.props.asset}
settings={this.props.settings}
onShowAll={this.showAll}
hasMore={this.props.relay.hasMore()}
disableShowAll={this.state.disableShowAll}
indentLevel={this.props.indentLevel}
ReplyListComponent={this.props.ReplyListComponent}
localReply={this.props.localReply}
/>
);
@@ -94,9 +110,10 @@ function createReplyListContainer(
me: GraphQLTaggedNode;
asset: GraphQLTaggedNode;
comment: GraphQLTaggedNode;
settings: GraphQLTaggedNode;
},
query: GraphQLTaggedNode,
ReplyListComponent?: React.ComponentType<any>,
ReplyListComponent?: InnerProps["ReplyListComponent"],
localReply?: boolean
) {
return withProps({ indentLevel, ReplyListComponent, localReply })(
@@ -145,6 +162,12 @@ const ReplyListContainer5 = createReplyListContainer(
...LocalReplyListContainer_me
}
`,
settings: graphql`
fragment ReplyListContainer5_settings on Settings {
...LocalReplyListContainer_settings
...CommentContainer_settings
}
`,
asset: graphql`
fragment ReplyListContainer5_asset on Asset {
...CommentContainer_asset
@@ -201,6 +224,12 @@ const ReplyListContainer4 = createReplyListContainer(
...CommentContainer_me
}
`,
settings: graphql`
fragment ReplyListContainer4_settings on Settings {
...ReplyListContainer5_settings
...CommentContainer_settings
}
`,
asset: graphql`
fragment ReplyListContainer4_asset on Asset {
...ReplyListContainer5_asset
@@ -255,6 +284,12 @@ const ReplyListContainer3 = createReplyListContainer(
...CommentContainer_me
}
`,
settings: graphql`
fragment ReplyListContainer3_settings on Settings {
...ReplyListContainer4_settings
...CommentContainer_settings
}
`,
asset: graphql`
fragment ReplyListContainer3_asset on Asset {
...ReplyListContainer4_asset
@@ -309,6 +344,12 @@ const ReplyListContainer2 = createReplyListContainer(
...CommentContainer_me
}
`,
settings: graphql`
fragment ReplyListContainer2_settings on Settings {
...ReplyListContainer3_settings
...CommentContainer_settings
}
`,
asset: graphql`
fragment ReplyListContainer2_asset on Asset {
...ReplyListContainer3_asset
@@ -363,6 +404,12 @@ const ReplyListContainer1 = createReplyListContainer(
...CommentContainer_me
}
`,
settings: graphql`
fragment ReplyListContainer1_settings on Settings {
...ReplyListContainer2_settings
...CommentContainer_settings
}
`,
asset: graphql`
fragment ReplyListContainer1_asset on Asset {
...ReplyListContainer2_asset
@@ -21,6 +21,12 @@ it("renders correctly", () => {
},
},
me: null,
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
relay: {
hasMore: noop,
isLoading: noop,
@@ -41,6 +47,12 @@ describe("when has more comments", () => {
},
},
me: null,
settings: {
reaction: {
icon: "thumb_up_alt",
label: "Respect",
},
},
relay: {
hasMore: () => true,
isLoading: () => false,
@@ -5,6 +5,7 @@ import { withPaginationContainer } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import { StreamContainer_asset as AssetData } from "talk-stream/__generated__/StreamContainer_asset.graphql";
import { StreamContainer_me as MeData } from "talk-stream/__generated__/StreamContainer_me.graphql";
import { StreamContainer_settings as SettingsData } from "talk-stream/__generated__/StreamContainer_settings.graphql";
import {
COMMENT_SORT,
StreamContainerPaginationQueryVariables,
@@ -14,6 +15,7 @@ import Stream from "../components/Stream";
interface InnerProps {
asset: AssetData;
settings: SettingsData;
me: MeData | null;
relay: RelayPaginationProp;
}
@@ -38,6 +40,7 @@ export class StreamContainer extends React.Component<InnerProps> {
<Stream
asset={this.props.asset}
comments={comments}
settings={this.props.settings}
onLoadMore={this.loadMore}
hasMore={this.props.relay.hasMore()}
disableLoadMore={this.state.disableLoadMore}
@@ -105,6 +108,12 @@ const enhanced = withPaginationContainer<
...UserBoxContainer_me
}
`,
settings: graphql`
fragment StreamContainer_settings on Settings {
...ReplyListContainer1_settings
...CommentContainer_settings
}
`,
},
{
direction: "forward",
@@ -2,7 +2,6 @@
exports[`renders correctly 1`] = `
<ReplyList
ReplyListComponent={[Function]}
asset={
Object {
"id": "asset-id",
@@ -31,10 +30,52 @@ exports[`renders correctly 1`] = `
Array [
Object {
"id": "comment-1",
"replyListElement": <ReplyListComponent
asset={
Object {
"id": "asset-id",
}
}
comment={
Object {
"id": "comment-1",
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>,
"showConversationLink": false,
},
Object {
"id": "comment-2",
"replyListElement": <ReplyListComponent
asset={
Object {
"id": "asset-id",
}
}
comment={
Object {
"id": "comment-2",
}
}
me={null}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>,
"showConversationLink": false,
},
]
@@ -44,6 +85,14 @@ exports[`renders correctly 1`] = `
localReply={false}
me={null}
onShowAll={[Function]}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
`;
@@ -79,10 +128,12 @@ exports[`when has more replies renders hasMore 1`] = `
Array [
Object {
"id": "comment-1",
"replyListElement": undefined,
"showConversationLink": false,
},
Object {
"id": "comment-2",
"replyListElement": undefined,
"showConversationLink": false,
},
]
@@ -93,6 +144,14 @@ exports[`when has more replies renders hasMore 1`] = `
localReply={false}
me={null}
onShowAll={[Function]}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
`;
@@ -126,10 +185,12 @@ exports[`when has more replies when showing all disables show all button 1`] = `
Array [
Object {
"id": "comment-1",
"replyListElement": undefined,
"showConversationLink": false,
},
Object {
"id": "comment-2",
"replyListElement": undefined,
"showConversationLink": false,
},
]
@@ -140,6 +201,14 @@ exports[`when has more replies when showing all disables show all button 1`] = `
localReply={false}
me={null}
onShowAll={[Function]}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
`;
@@ -173,10 +242,12 @@ exports[`when has more replies when showing all enable show all button after loa
Array [
Object {
"id": "comment-1",
"replyListElement": undefined,
"showConversationLink": false,
},
Object {
"id": "comment-2",
"replyListElement": undefined,
"showConversationLink": false,
},
]
@@ -187,5 +258,13 @@ exports[`when has more replies when showing all enable show all button after loa
localReply={false}
me={null}
onShowAll={[Function]}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
`;
@@ -35,6 +35,14 @@ exports[`renders correctly 1`] = `
disableLoadMore={false}
me={null}
onLoadMore={[Function]}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
`;
@@ -74,6 +82,14 @@ exports[`when has more comments renders hasMore 1`] = `
hasMore={true}
me={null}
onLoadMore={[Function]}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
`;
@@ -113,6 +129,14 @@ exports[`when has more comments when loading more disables load more button 1`]
hasMore={true}
me={null}
onLoadMore={[Function]}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
`;
@@ -152,5 +176,13 @@ exports[`when has more comments when loading more enable load more button after
hasMore={true}
me={null}
onLoadMore={[Function]}
settings={
Object {
"reaction": Object {
"icon": "thumb_up_alt",
"label": "Respect",
},
}
}
/>
`;
@@ -36,6 +36,7 @@ export const render = ({
return (
<PermalinkViewContainer
me={props.me}
settings={props.settings}
comment={props.comment}
asset={props.asset}
/>
@@ -63,6 +64,9 @@ const PermalinkViewQuery: StatelessComponent<InnerProps> = ({
comment(id: $commentID) {
...PermalinkViewContainer_comment
}
settings {
...PermalinkViewContainer_settings
}
}
`}
variables={{
@@ -31,7 +31,13 @@ export const render = ({
</Localized>
);
}
return <StreamContainer me={props.me} asset={props.asset} />;
return (
<StreamContainer
settings={props.settings}
me={props.me}
asset={props.asset}
/>
);
}
return <Spinner />;
@@ -49,6 +55,9 @@ const StreamQuery: StatelessComponent<InnerProps> = ({
asset(id: $assetID, url: $assetURL) {
...StreamContainer_asset
}
settings {
...StreamContainer_settings
}
}
`}
variables={{
@@ -323,6 +323,21 @@ exports[`cancel edit: edit canceled 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -359,10 +374,10 @@ exports[`cancel edit: edit canceled 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -397,6 +412,21 @@ exports[`cancel edit: edit canceled 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -843,10 +873,10 @@ exports[`edit a comment: edit form 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -881,6 +911,21 @@ exports[`edit a comment: edit form 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1327,10 +1372,10 @@ exports[`edit a comment: optimistic response 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -1365,6 +1410,21 @@ exports[`edit a comment: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1700,6 +1760,21 @@ exports[`edit a comment: render stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1736,10 +1811,10 @@ exports[`edit a comment: render stream 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -1774,6 +1849,21 @@ exports[`edit a comment: render stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -2118,6 +2208,21 @@ exports[`edit a comment: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -2154,10 +2259,10 @@ exports[`edit a comment: server response 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -2192,6 +2297,21 @@ exports[`edit a comment: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -2527,6 +2647,21 @@ exports[`shows expiry message: edit form closed 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -2563,10 +2698,10 @@ exports[`shows expiry message: edit form closed 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -2601,6 +2736,21 @@ exports[`shows expiry message: edit form closed 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -3024,10 +3174,10 @@ exports[`shows expiry message: edit time expired 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -3062,6 +3212,21 @@ exports[`shows expiry message: edit time expired 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -282,10 +282,10 @@ exports[`loads more comments 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -356,10 +356,10 @@ exports[`loads more comments 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:14:00.000Z"
title="2018-07-06T18:14:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:14:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -688,10 +688,10 @@ exports[`renders comment stream 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -317,6 +317,21 @@ exports[`post a comment: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -391,6 +406,21 @@ exports[`post a comment: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -427,10 +457,10 @@ exports[`post a comment: optimistic response 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -465,6 +495,21 @@ exports[`post a comment: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -784,6 +829,21 @@ exports[`post a comment: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -858,6 +918,21 @@ exports[`post a comment: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -894,10 +969,10 @@ exports[`post a comment: server response 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -932,6 +1007,21 @@ exports[`post a comment: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1251,6 +1341,21 @@ exports[`renders comment stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1287,10 +1392,10 @@ exports[`renders comment stream 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -1325,6 +1430,21 @@ exports[`renders comment stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -307,6 +307,21 @@ exports[`post a reply: open reply form 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -385,6 +400,21 @@ exports[`post a reply: open reply form 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -463,6 +493,21 @@ exports[`post a reply: open reply form 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -541,6 +586,21 @@ exports[`post a reply: open reply form 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -619,6 +679,21 @@ exports[`post a reply: open reply form 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -697,6 +772,21 @@ exports[`post a reply: open reply form 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantUnderlined"
@@ -1169,6 +1259,21 @@ exports[`post a reply: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1247,6 +1352,21 @@ exports[`post a reply: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1325,6 +1445,21 @@ exports[`post a reply: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1403,6 +1538,21 @@ exports[`post a reply: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1481,6 +1631,21 @@ exports[`post a reply: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1559,6 +1724,21 @@ exports[`post a reply: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantUnderlined"
@@ -1773,7 +1953,23 @@ exports[`post a reply: optimistic response 1`] = `
/>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-directionRow"
/>
>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
</div>
@@ -2104,6 +2300,21 @@ exports[`post a reply: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -2182,6 +2393,21 @@ exports[`post a reply: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -2260,6 +2486,21 @@ exports[`post a reply: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -2338,6 +2579,21 @@ exports[`post a reply: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -2416,6 +2672,21 @@ exports[`post a reply: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -2494,6 +2765,21 @@ exports[`post a reply: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantUnderlined"
@@ -2571,7 +2857,23 @@ exports[`post a reply: server response 1`] = `
/>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-directionRow"
/>
>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
</div>
@@ -2902,6 +3204,21 @@ exports[`renders comment stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -2980,6 +3297,21 @@ exports[`renders comment stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -3058,6 +3390,21 @@ exports[`renders comment stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -3136,6 +3483,21 @@ exports[`renders comment stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -3214,6 +3576,21 @@ exports[`renders comment stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -3292,6 +3669,21 @@ exports[`renders comment stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantUnderlined"
@@ -307,6 +307,21 @@ exports[`post a reply: open reply form 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -470,10 +485,10 @@ exports[`post a reply: open reply form 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -508,6 +523,21 @@ exports[`post a reply: open reply form 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -827,6 +857,21 @@ exports[`post a reply: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1042,6 +1087,21 @@ exports[`post a reply: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1080,10 +1140,10 @@ exports[`post a reply: optimistic response 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -1118,6 +1178,21 @@ exports[`post a reply: optimistic response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1437,6 +1512,21 @@ exports[`post a reply: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1515,6 +1605,21 @@ exports[`post a reply: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1553,10 +1658,10 @@ exports[`post a reply: server response 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -1591,6 +1696,21 @@ exports[`post a reply: server response 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1910,6 +2030,21 @@ exports[`renders comment stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -1946,10 +2081,10 @@ exports[`renders comment stream 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -1984,6 +2119,21 @@ exports[`renders comment stream 1`] = `
Reply
</span>
</button>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
</div>
</div>
@@ -27,7 +27,7 @@ exports[`renders comment stream 1`] = `
role="tab"
type="button"
>
1 Comments
2 Comments
</button>
</li>
</ul>
@@ -438,10 +438,10 @@ exports[`renders comment stream 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:14:00.000Z"
title="2018-07-06T18:14:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:14:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -512,10 +512,10 @@ exports[`renders comment stream 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:14:00.000Z"
title="2018-07-06T18:14:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:14:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -588,10 +588,10 @@ exports[`renders comment stream 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:14:00.000Z"
title="2018-07-06T18:14:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:14:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -282,10 +282,10 @@ exports[`renders comment stream 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -286,10 +286,10 @@ exports[`renders comment stream 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -648,10 +648,10 @@ exports[`show all replies 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:20:00.000Z"
title="2018-07-06T18:20:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:20:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -722,10 +722,10 @@ exports[`show all replies 1`] = `
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:14:00.000Z"
title="2018-07-06T18:14:00.000Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:14:00.000Z
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
@@ -1,9 +1,10 @@
import sinon from "sinon";
import timekeeper from "timekeeper";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assets, users } from "../fixtures";
import { assets, settings, users } from "../fixtures";
import create from "./create";
function createTestRenderer() {
@@ -16,10 +17,8 @@ function createTestRenderer() {
.withArgs(undefined, { id: assets[0].id, url: null })
.returns(assets[0])
),
me: createSinonStub(
s => s.throws(),
s => s.withArgs(undefined).returns(users[0])
),
me: sinon.stub().returns(users[0]),
settings: sinon.stub().returns(settings),
},
Mutation: {
editComment: createSinonStub(
@@ -4,7 +4,7 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assets, comments } from "../fixtures";
import { assets, comments, settings } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
@@ -66,6 +66,7 @@ beforeEach(() => {
)
.returns(assetStub)
),
settings: sinon.stub().returns(settings),
},
};
@@ -4,7 +4,7 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assets, comments } from "../fixtures";
import { assets, comments, settings } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
@@ -41,6 +41,7 @@ beforeEach(() => {
.withArgs(undefined, { id: assetStub.id, url: null })
.returns(assetStub)
),
settings: sinon.stub().returns(settings),
},
};
@@ -1,7 +1,9 @@
import { ReactTestRenderer } from "react-test-renderer";
import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { settings } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
@@ -10,6 +12,7 @@ beforeEach(() => {
Query: {
comment: () => null,
asset: () => null,
settings: sinon.stub().returns(settings),
},
};
@@ -4,7 +4,7 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assets, comments } from "../fixtures";
import { assets, comments, settings } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
@@ -38,6 +38,7 @@ beforeEach(() => {
.withArgs(undefined, { id: assetStub.id, url: null })
.returns(assetStub)
),
settings: sinon.stub().returns(settings),
},
};
@@ -1,18 +1,26 @@
import { ReactTestRenderer } from "react-test-renderer";
import sinon from "sinon";
import timekeeper from "timekeeper";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assets, users } from "../fixtures";
import { assets, baseComment, settings, users } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
const resolvers = {
Query: {
asset: createSinonStub(s => s.throws(), s => s.returns(assets[0])),
me: createSinonStub(s => s.throws(), s => s.returns(users[0])),
settings: sinon.stub().returns(settings),
me: sinon.stub().returns(users[0]),
asset: createSinonStub(
s => s.throws(),
s =>
s
.withArgs(undefined, { id: assets[0].id, url: null })
.returns(assets[0])
),
},
Mutation: {
createComment: createSinonStub(
@@ -31,15 +39,10 @@ beforeEach(() => {
edge: {
cursor: null,
node: {
...baseComment,
id: "comment-x",
author: users[0],
body: "<strong>Hello world! (from server)</strong>",
createdAt: "2018-07-06T18:24:00.000Z",
editing: {
edited: false,
editableUntil: "2018-07-06T18:24:30.000Z",
},
replies: { edges: [], pageInfo: {} },
},
},
clientMutationId: "0",
@@ -71,7 +74,7 @@ it("post a comment", async () => {
.findByProps({ inputId: "comments-postCommentForm-field" })
.props.onChange({ html: "<strong>Hello world!</strong>" });
timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z"));
timekeeper.freeze(new Date(baseComment.createdAt));
testRenderer.root
.findByProps({ id: "comments-postCommentForm-form" })
@@ -1,21 +1,31 @@
import { ReactTestRenderer } from "react-test-renderer";
import sinon from "sinon";
import timekeeper from "timekeeper";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assetWithDeepestReplies, users } from "../fixtures";
import {
assetWithDeepestReplies,
baseComment,
settings,
users,
} from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
const resolvers = {
Query: {
settings: sinon.stub().returns(settings),
me: sinon.stub().returns(users[0]),
asset: createSinonStub(
s => s.throws(),
s => s.returns(assetWithDeepestReplies)
s =>
s
.withArgs(undefined, { id: assetWithDeepestReplies.id, url: null })
.returns(assetWithDeepestReplies)
),
me: createSinonStub(s => s.throws(), s => s.returns(users[0])),
},
Mutation: {
createComment: createSinonStub(
@@ -34,18 +44,10 @@ beforeEach(() => {
edge: {
cursor: null,
node: {
...baseComment,
id: "comment-x",
author: users[0],
body: "<strong>Hello world! (from server)</strong>",
createdAt: "2018-07-06T18:24:00.000Z",
replies: {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
},
editing: {
edited: false,
editableUntil: "2018-07-06T18:24:30.000Z",
},
},
},
clientMutationId: "0",
@@ -92,7 +94,7 @@ it("post a reply", async () => {
})
.props.onChange({ html: "<strong>Hello world!</strong>" });
timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z"));
timekeeper.freeze(new Date(baseComment.createdAt));
testRenderer.root
.findByProps({
id: "comments-replyCommentForm-form-comment-with-deepest-replies-5",
@@ -1,18 +1,26 @@
import { ReactTestRenderer } from "react-test-renderer";
import sinon from "sinon";
import timekeeper from "timekeeper";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assets, users } from "../fixtures";
import { assets, baseComment, settings, users } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
const resolvers = {
Query: {
asset: createSinonStub(s => s.throws(), s => s.returns(assets[0])),
me: createSinonStub(s => s.throws(), s => s.returns(users[0])),
settings: sinon.stub().returns(settings),
me: sinon.stub().returns(users[0]),
asset: createSinonStub(
s => s.throws(),
s =>
s
.withArgs(undefined, { id: assets[0].id, url: null })
.returns(assets[0])
),
},
Mutation: {
createComment: createSinonStub(
@@ -31,18 +39,10 @@ beforeEach(() => {
edge: {
cursor: null,
node: {
...baseComment,
id: "comment-x",
author: users[0],
body: "<strong>Hello world! (from server)</strong>",
createdAt: "2018-07-06T18:24:00.000Z",
replies: {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
},
editing: {
edited: false,
editableUntil: "2018-07-06T18:24:30.000Z",
},
},
},
clientMutationId: "0",
@@ -84,7 +84,7 @@ it("post a reply", async () => {
.findByProps({ inputId: "comments-replyCommentForm-rte-comment-0" })
.props.onChange({ html: "<strong>Hello world!</strong>" });
timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z"));
timekeeper.freeze(new Date(baseComment.createdAt));
testRenderer.root
.findByProps({ id: "comments-replyCommentForm-form-comment-0" })
.props.onSubmit();
@@ -1,9 +1,10 @@
import { ReactTestRenderer } from "react-test-renderer";
import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assetWithDeepReplies } from "../fixtures";
import { assetWithDeepReplies, settings } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
@@ -17,6 +18,7 @@ beforeEach(() => {
.withArgs(undefined, { id: assetWithDeepReplies.id, url: null })
.returns(assetWithDeepReplies)
),
settings: sinon.stub().returns(settings),
},
};
@@ -1,9 +1,10 @@
import { ReactTestRenderer } from "react-test-renderer";
import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assets } from "../fixtures";
import { assets, settings } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
@@ -17,6 +18,7 @@ beforeEach(() => {
.withArgs(undefined, { id: assets[0].id, url: null })
.returns(assets[0])
),
settings: sinon.stub().returns(settings),
},
};
@@ -4,7 +4,7 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assets, comments } from "../fixtures";
import { assets, comments, settings } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
@@ -65,6 +65,7 @@ beforeEach(() => {
const resolvers = {
Query: {
settings: sinon.stub().returns(settings),
comment: createSinonStub(
s => s.throws(),
s => s.withArgs(undefined, { id: commentStub.id }).returns(commentStub)
@@ -4,7 +4,7 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assetWithDeepestReplies, comments } from "../fixtures";
import { assetWithDeepestReplies, comments, settings } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
@@ -25,6 +25,7 @@ beforeEach(() => {
id: "comment-with-deepest-replies-5",
})
),
settings: sinon.stub().returns(settings),
},
};
+126 -133
View File
@@ -1,3 +1,11 @@
export const settings = {
reaction: {
icon: "thumb_up",
label: "Respect",
labelActive: "Respected",
},
};
export const users = [
{
id: "user-0",
@@ -13,106 +21,67 @@ export const users = [
},
];
export const baseComment = {
author: users[0],
body: "Comment Body",
createdAt: "2018-07-06T18:24:00.000Z",
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
replyCount: 0,
editing: {
edited: false,
editableUntil: "2018-07-06T18:24:30.000Z",
},
actionCounts: {
reaction: {
total: 0,
},
},
};
export const comments = [
{
...baseComment,
id: "comment-0",
author: users[0],
body: "Joining Too",
createdAt: "2018-07-06T18:24:00.000Z",
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
replyCount: 0,
editing: {
edited: false,
editableUntil: "2018-07-06T18:24:30.000Z",
},
},
{
...baseComment,
id: "comment-1",
author: users[1],
body: "What's up?",
createdAt: "2018-07-06T18:20:00.000Z",
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
replyCount: 0,
editing: {
edited: false,
editableUntil: "2018-07-06T18:20:30.000Z",
},
},
{
...baseComment,
id: "comment-2",
author: users[2],
body: "Hey!",
createdAt: "2018-07-06T18:14:00.000Z",
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
replyCount: 0,
editing: {
edited: false,
editableUntil: "2018-07-06T18:14:30.000Z",
},
},
{
...baseComment,
id: "comment-3",
author: users[2],
body: "Comment Body 3",
createdAt: "2018-07-06T18:14:00.000Z",
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
replyCount: 0,
editing: {
edited: false,
editableUntil: "2018-07-06T18:14:30.000Z",
},
},
{
...baseComment,
id: "comment-4",
author: users[2],
body: "Comment Body 4",
createdAt: "2018-07-06T18:14:00.000Z",
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
replyCount: 0,
editing: {
edited: false,
editableUntil: "2018-07-06T18:14:30.000Z",
},
},
{
...baseComment,
id: "comment-5",
author: users[2],
body: "Comment Body 5",
createdAt: "2018-07-06T18:14:00.000Z",
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
replyCount: 0,
editing: {
edited: false,
editableUntil: "2018-07-06T18:14:30.000Z",
},
},
];
export const assets = [
{
id: "asset-1",
url: "http://localhost/assets/asset-1",
isClosed: false,
commentCounts: {
totalVisible: 2,
},
comments: {
edges: [
{ node: comments[0], cursor: comments[0].createdAt },
{ node: comments[1], cursor: comments[1].createdAt },
],
pageInfo: {
hasNextPage: false,
},
},
},
];
export const commentWithReplies = {
...baseComment,
id: "comment-with-replies",
author: users[0],
body: "I like yoghurt",
createdAt: "2018-07-06T18:24:00.000Z",
replies: {
edges: [
{ node: comments[3], cursor: comments[3].createdAt },
@@ -123,17 +92,13 @@ export const commentWithReplies = {
},
},
replyCount: 2,
editing: {
edited: false,
editableUntil: "2018-07-06T18:24:30.000Z",
},
};
export const commentWithDeepReplies = {
...baseComment,
id: "comment-with-deep-replies",
author: users[0],
body: "I like yoghurt",
createdAt: "2018-07-06T18:24:00.000Z",
replies: {
edges: [
{ node: commentWithReplies, cursor: commentWithReplies.createdAt },
@@ -144,120 +109,76 @@ export const commentWithDeepReplies = {
},
},
replyCount: 2,
editing: {
edited: false,
editableUntil: "2018-07-06T18:24:30.000Z",
},
};
export const assetWithReplies = {
id: "asset-with-replies",
url: "http://localhost/assets/asset-with-replies",
isClosed: false,
comments: {
edges: [
{ node: comments[0], cursor: comments[0].createdAt },
{ node: commentWithReplies, cursor: commentWithReplies.createdAt },
],
pageInfo: {
hasNextPage: false,
},
},
commentCounts: {
totalVisible: 1,
},
};
export const assetWithDeepReplies = {
id: "asset-with-deep-replies",
url: "http://localhost/assets/asset-with-replies",
isClosed: false,
comments: {
edges: [
{ node: comments[0], cursor: comments[0].createdAt },
{
node: commentWithDeepReplies,
cursor: commentWithDeepReplies.createdAt,
},
],
pageInfo: {
hasNextPage: false,
},
},
commentCounts: {
totalVisible: 1,
},
};
export const commentWithDeepestReplies = {
...commentWithReplies,
...baseComment,
id: "comment-with-deepest-replies",
body: "body 0",
replyCount: 1,
replies: {
...commentWithReplies.replies,
...baseComment.replies,
edges: [
{
cursor: commentWithReplies.createdAt,
cursor: baseComment.createdAt,
node: {
...commentWithReplies,
...baseComment,
id: "comment-with-deepest-replies-1",
body: "body 1",
replyCount: 1,
replies: {
...commentWithReplies.replies,
...baseComment.replies,
edges: [
{
cursor: commentWithReplies.createdAt,
cursor: baseComment.createdAt,
node: {
...commentWithReplies,
...baseComment,
id: "comment-with-deepest-replies-2",
body: "body 2",
replyCount: 1,
replies: {
...commentWithReplies.replies,
...baseComment.replies,
edges: [
{
cursor: commentWithReplies.createdAt,
cursor: baseComment.createdAt,
node: {
...commentWithReplies,
...baseComment,
id: "comment-with-deepest-replies-3",
body: "body 3",
replyCount: 1,
replies: {
...commentWithReplies.replies,
...baseComment.replies,
edges: [
{
cursor: commentWithReplies.createdAt,
cursor: baseComment.createdAt,
node: {
...commentWithReplies,
...baseComment,
id: "comment-with-deepest-replies-4",
body: "body 4",
replyCount: 1,
replies: {
...commentWithReplies.replies,
...baseComment.replies,
edges: [
{
cursor: commentWithReplies.createdAt,
cursor: baseComment.createdAt,
node: {
...commentWithReplies,
...baseComment,
id: "comment-with-deepest-replies-5",
body: "body 5",
replyCount: 1,
replies: {
...commentWithReplies.replies,
...baseComment.replies,
edges: [
{
cursor:
commentWithReplies.createdAt,
cursor: baseComment.createdAt,
node: {
...commentWithReplies,
...baseComment,
id:
"comment-with-deepest-replies-6",
body: "body 6",
replyCount: 1,
replies: {
...commentWithReplies.replies,
...baseComment.replies,
edges: [],
},
},
@@ -286,10 +207,82 @@ export const commentWithDeepestReplies = {
},
};
export const baseAsset = {
isClosed: false,
comments: {
edges: [],
pageInfo: {
hasNextPage: false,
},
},
commentCounts: {
totalVisible: 0,
},
};
export const assets = [
{
...baseAsset,
id: "asset-1",
url: "http://localhost/assets/asset-1",
comments: {
edges: [
{ node: comments[0], cursor: comments[0].createdAt },
{ node: comments[1], cursor: comments[1].createdAt },
],
pageInfo: {
hasNextPage: false,
},
},
commentCounts: {
totalVisible: 2,
},
},
];
export const assetWithReplies = {
...baseAsset,
id: "asset-with-replies",
url: "http://localhost/assets/asset-with-replies",
comments: {
edges: [
{ node: comments[0], cursor: comments[0].createdAt },
{ node: commentWithReplies, cursor: commentWithReplies.createdAt },
],
pageInfo: {
hasNextPage: false,
},
},
commentCounts: {
totalVisible: 2,
},
};
export const assetWithDeepReplies = {
...baseAsset,
id: "asset-with-deep-replies",
url: "http://localhost/assets/asset-with-replies",
comments: {
edges: [
{ node: comments[0], cursor: comments[0].createdAt },
{
node: commentWithDeepReplies,
cursor: commentWithDeepReplies.createdAt,
},
],
pageInfo: {
hasNextPage: false,
},
},
commentCounts: {
totalVisible: 2,
},
};
export const assetWithDeepestReplies = {
...baseAsset,
id: "asset-with-deepest-replies",
url: "http://localhost/assets/asset-with-replies",
isClosed: false,
comments: {
edges: [
{
@@ -1,4 +1,7 @@
.root {
}
.popover {
background: var(--palette-common-white);
border: 1px solid var(--palette-grey-lighter);
box-sizing: border-box;
@@ -7,6 +7,9 @@ import {
Reference,
RefHandler,
} from "react-popper";
import { withStyles } from "talk-ui/hocs";
import AriaInfo from "../AriaInfo";
import * as styles from "./Popover.css";
@@ -43,6 +46,7 @@ interface PopoverProps {
onClose?: () => void;
className?: string;
placement?: Placement;
classes: typeof styles;
}
interface State {
@@ -50,7 +54,7 @@ interface State {
}
class Popover extends React.Component<PopoverProps> {
public static defaultProps = {
public static defaultProps: Partial<PopoverProps> = {
placement: "top",
};
public state: State = {
@@ -92,56 +96,61 @@ class Popover extends React.Component<PopoverProps> {
description,
className,
placement,
classes,
} = this.props;
const { visible } = this.state;
const popoverClassName = cn(styles.root, className, {
[styles.top]: placement!.startsWith("top"),
[styles.left]: placement!.startsWith("left"),
[styles.right]: placement!.startsWith("right"),
[styles.bottom]: placement!.startsWith("bottom"),
const popoverClassName = cn(classes.popover, {
[classes.top]: placement!.startsWith("top"),
[classes.left]: placement!.startsWith("left"),
[classes.right]: placement!.startsWith("right"),
[classes.bottom]: placement!.startsWith("bottom"),
});
return (
<Manager>
<Reference>
{(props: PopperArrowProps) =>
children({
forwardRef: props.ref,
toggleVisibility: this.toggleVisibility,
visible: this.state.visible,
})
}
</Reference>
<Popper placement={placement} eventsEnabled positionFixed={false}>
{(props: PopperArrowProps) => (
<div
id={id}
role="popup"
aria-labelledby={`${id}-ariainfo`}
aria-hidden={!visible}
>
<AriaInfo id={`${id}-ariainfo`}>{description}</AriaInfo>
{visible && (
<div
style={props.style}
className={popoverClassName}
ref={props.ref}
>
{typeof body === "function"
? body({
toggleVisibility: this.toggleVisibility,
visible: this.state.visible,
})
: body}
</div>
)}
</div>
)}
</Popper>
</Manager>
<div className={cn(classes.root, className)}>
<Manager>
<Reference>
{(props: PopperArrowProps) =>
children({
forwardRef: props.ref,
toggleVisibility: this.toggleVisibility,
visible: this.state.visible,
})
}
</Reference>
<Popper placement={placement} eventsEnabled positionFixed={false}>
{(props: PopperArrowProps) => (
<div
id={id}
role="popup"
aria-labelledby={`${id}-ariainfo`}
aria-hidden={!visible}
>
<AriaInfo id={`${id}-ariainfo`}>{description}</AriaInfo>
{visible && (
<div
style={props.style}
className={popoverClassName}
ref={props.ref}
>
{typeof body === "function"
? body({
toggleVisibility: this.toggleVisibility,
visible: this.state.visible,
})
: body}
</div>
)}
</div>
)}
</Popper>
</Manager>
</div>
);
}
}
export default Popover;
const enhanced = withStyles(styles)(Popover);
export default enhanced;
+1 -1
View File
@@ -97,7 +97,7 @@ function setupViews(options: AppOptions) {
const { parent } = options;
// configure the default views directory.
const views = path.join(__dirname, "..", "..", "..", "static");
const views = path.join(__dirname, "..", "..", "..", "..", "dist", "static");
parent.set("views", views);
// Reconfigure nunjucks.
+2 -8
View File
@@ -9,15 +9,9 @@ export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
// TODO: handle better when we improve errors.
if (err.message === "not found") {
// TODO: handle better when we improve errors.
res
.status(404)
.send(err.message)
.end();
res.status(404).send(err.message);
} else {
// TODO: handle better when we improve errors.
res
.status(500)
.send(err.message)
.end();
res.status(500).send(err.message);
}
};
+14 -1
View File
@@ -1,4 +1,17 @@
import { RequestHandler } from "express";
import { MiddlewareOptions } from "graphql-playground-html";
import playground from "graphql-playground-middleware-express";
export default (options: MiddlewareOptions) => playground(options);
export default (options: MiddlewareOptions): RequestHandler => (
req,
res,
next
) => {
try {
playground(options)(req, res, () => {
// The playground calls next() when it's not supposed to.
});
} catch (err) {
return next(err);
}
};
@@ -2,7 +2,7 @@ import serveStatic from "express-static-gzip";
import path from "path";
const staticPath = path.resolve(
path.join(__dirname, "..", "..", "..", "..", "static", "assets")
path.join(__dirname, "..", "..", "..", "..", "..", "dist", "static", "assets")
);
export default serveStatic(staticPath, { index: false });
@@ -5,8 +5,13 @@ import {
AssetToCommentsArgs,
CommentToParentsArgs,
CommentToRepliesArgs,
GQLActionPresence,
GQLCOMMENT_SORT,
} from "talk-server/graph/tenant/schema/__generated__/types";
import {
ACTION_ITEM_TYPE,
retrieveManyUserActionPresence,
} from "talk-server/models/action";
import {
Comment,
retrieveCommentAssetConnection,
@@ -38,6 +43,17 @@ export default (ctx: Context) => ({
comment: new DataLoader((ids: string[]) =>
retrieveManyComments(ctx.mongo, ctx.tenant.id, ids)
),
retrieveMyActionPresence: new DataLoader<string, GQLActionPresence>(
(itemIDs: string[]) =>
retrieveManyUserActionPresence(
ctx.mongo,
ctx.tenant.id,
// This should only ever be accessed when a user is logged in.
ctx.user!.id,
ACTION_ITEM_TYPE.COMMENTS,
itemIDs
)
),
forUser: (
userID: string,
// Apply the graph schema defaults at the loader.
@@ -1,9 +1,23 @@
import TenantContext from "talk-server/graph/tenant/context";
import {
GQLCreateCommentDontAgreeInput,
GQLCreateCommentFlagInput,
GQLCreateCommentInput,
GQLCreateCommentReactionInput,
GQLDeleteCommentDontAgreeInput,
GQLDeleteCommentFlagInput,
GQLDeleteCommentReactionInput,
GQLEditCommentInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { create, edit } from "talk-server/services/comments";
import {
createDontAgree,
createFlag,
createReaction,
deleteDontAgree,
deleteFlag,
deleteReaction,
} from "talk-server/services/comments/actions";
export default (ctx: TenantContext) => ({
create: (input: GQLCreateCommentInput) =>
@@ -30,4 +44,29 @@ export default (ctx: TenantContext) => ({
},
ctx.req
),
createReaction: (input: GQLCreateCommentReactionInput) =>
createReaction(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
}),
deleteReaction: (input: GQLDeleteCommentReactionInput) =>
deleteReaction(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
}),
createDontAgree: (input: GQLCreateCommentDontAgreeInput) =>
createDontAgree(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
}),
deleteDontAgree: (input: GQLDeleteCommentDontAgreeInput) =>
deleteDontAgree(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
}),
createFlag: (input: GQLCreateCommentFlagInput) =>
createFlag(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
reason: input.reason,
}),
deleteFlag: (input: GQLDeleteCommentFlagInput) =>
deleteFlag(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
}),
});
@@ -1,4 +1,5 @@
import { GQLAssetTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { decodeActionCounts } from "talk-server/models/action";
import { Asset } from "talk-server/models/asset";
const Asset: GQLAssetTypeResolver<Asset> = {
@@ -6,6 +7,7 @@ const Asset: GQLAssetTypeResolver<Asset> = {
ctx.loaders.Comments.forAsset(asset.id, input),
// TODO: implement this.
isClosed: () => false,
actionCounts: asset => decodeActionCounts(asset.action_counts),
commentCounts: asset => asset.comment_counts,
};
@@ -3,6 +3,7 @@ import {
GQLComment,
GQLCommentTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { decodeActionCounts } from "talk-server/models/action";
import { Comment } from "talk-server/models/comment";
import { createConnection } from "talk-server/models/connection";
@@ -24,6 +25,11 @@ const Comment: GQLCommentTypeResolver<Comment> = {
comment.reply_count > 0
? ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input)
: createConnection(),
actionCounts: comment => decodeActionCounts(comment.action_counts),
myActionPresence: (comment, input, ctx) =>
ctx.user
? ctx.loaders.Comments.retrieveMyActionPresence.load(comment.id)
: null,
parentCount: comment =>
comment.parent_id ? comment.grandparent_ids.length + 1 : 0,
depth: comment =>
@@ -9,10 +9,10 @@ const Mutation: GQLMutationTypeResolver<void> = {
const comment = await ctx.mutators.Comment.create(input);
return {
edge: {
// (cvle)
// Depending on the sort we can't determine the accurate cursor
// in a performant way, so we return null instead.
// It seems that Relay does not directly use this value...
// NOTE: (cvle)
// Depending on the sort we can't determine the accurate cursor in a
// performant way, so we return null instead. It seems that Relay does
// not directly use this value.
cursor: null,
node: comment,
},
@@ -23,6 +23,30 @@ const Mutation: GQLMutationTypeResolver<void> = {
settings: await ctx.mutators.Settings.update(input.settings),
clientMutationId: input.clientMutationId,
}),
createCommentReaction: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createReaction(input),
clientMutationId: input.clientMutationId,
}),
deleteCommentReaction: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.deleteReaction(input),
clientMutationId: input.clientMutationId,
}),
createCommentDontAgree: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createDontAgree(input),
clientMutationId: input.clientMutationId,
}),
deleteCommentDontAgree: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.deleteDontAgree(input),
clientMutationId: input.clientMutationId,
}),
createCommentFlag: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createFlag(input),
clientMutationId: input.clientMutationId,
}),
deleteCommentFlag: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.deleteFlag(input),
clientMutationId: input.clientMutationId,
}),
};
export default Mutation;
@@ -30,23 +30,181 @@ scalar Cursor
## Actions
################################################################################
enum ACTION_TYPE {
FLAG
DONTAGREE
"""
COMMENT_FLAG_REPORTED_REASON is a reason that is reported by a User on a
Comment.
"""
enum COMMENT_FLAG_REPORTED_REASON {
"""
COMMENT_REPORTED_OFFENSIVE is used when a User reported a Comment as being
offensive.
"""
COMMENT_REPORTED_OFFENSIVE
"""
COMMENT_REPORTED_SPAM is used when a User reported a Comment as appearing like
spam.
"""
COMMENT_REPORTED_SPAM
}
enum ACTION_ITEM_TYPE {
COMMENTS
"""
COMMENT_FLAG_DETECTED_REASON is a reason that is detected by the system on a
Comment.
"""
enum COMMENT_FLAG_DETECTED_REASON {
"""
COMMENT_DETECTED_TOXIC is used when the Comment was detected as being toxic by
the system.
"""
COMMENT_DETECTED_TOXIC
"""
COMMENT_DETECTED_SPAM is used when the Comment was detected as having spam by
the system.
"""
COMMENT_DETECTED_SPAM
"""
COMMENT_DETECTED_BODY_COUNT is used when the Comment was detected as exceeding
the body length by the system.
"""
COMMENT_DETECTED_BODY_COUNT
"""
COMMENT_DETECTED_TRUST is used when the Comment being left was done by a User
that has a low karma/trust score.
"""
COMMENT_DETECTED_TRUST
"""
COMMENT_DETECTED_LINKS is used when the Comment was detected as containing
links.
"""
COMMENT_DETECTED_LINKS
"""
COMMENT_DETECTED_BANNED_WORD is used when the Comment was detected as
containing a banned word.
"""
COMMENT_DETECTED_BANNED_WORD
"""
COMMENT_DETECTED_SUSPECT_WORD is used when the Comment was detected as
containing a suspect word.
"""
COMMENT_DETECTED_SUSPECT_WORD
}
enum ACTION_GROUP {
SPAM_COMMENT
TOXIC_COMMENT
BODY_COUNT
TRUST
LINKS
BANNED_WORD
SUSPECT_WORD
"""
COMMENT_FLAG_REASON is the union of the COMMENT_FLAG_REPORTED_REASON
and COMMENT_FLAG_DETECTED_REASON types.
"""
enum COMMENT_FLAG_REASON {
COMMENT_REPORTED_OFFENSIVE
COMMENT_REPORTED_SPAM
COMMENT_DETECTED_TOXIC
COMMENT_DETECTED_SPAM
COMMENT_DETECTED_BODY_COUNT
COMMENT_DETECTED_TRUST
COMMENT_DETECTED_LINKS
COMMENT_DETECTED_BANNED_WORD
COMMENT_DETECTED_SUSPECT_WORD
}
"""
ReactionActionCounts stores all the counts for the counts for the reaction
action on a given item.
"""
type ReactionActionCounts {
"""
total is the total number of reactions against a given item.
"""
total: Int!
}
"""
DontAgreeActionCounts stores all the counts for the counts for the dontAgree
action on a given item.
"""
type DontAgreeActionCounts {
"""
total is the total number of dontAgree actions against a given item.
"""
total: Int!
}
type FlagReasonActionCounts {
COMMENT_REPORTED_OFFENSIVE: Int!
COMMENT_REPORTED_SPAM: Int!
COMMENT_DETECTED_TOXIC: Int!
COMMENT_DETECTED_SPAM: Int!
COMMENT_DETECTED_BODY_COUNT: Int!
COMMENT_DETECTED_TRUST: Int!
COMMENT_DETECTED_LINKS: Int!
COMMENT_DETECTED_BANNED_WORD: Int!
COMMENT_DETECTED_SUSPECT_WORD: Int!
}
"""
FlagActionCounts stores all the counts for the counts for the flag action on a
given item and the reason counts.
"""
type FlagActionCounts {
"""
total is the total number of flags against a given item.
"""
total: Int!
"""
reasons stores the counts for the various reasons that an item could be
flagged for.
"""
reasons: FlagReasonActionCounts!
}
"""
ActionCounts returns the counts of each action for an item.
"""
type ActionCounts {
"""
reaction returns the counts for the reaction action on an item.
"""
reaction: ReactionActionCounts!
"""
dontAgree returns the counts for the dontAgree action on an item. This edge is
restricted to administrators and moderators.
"""
dontAgree: DontAgreeActionCounts! @auth(roles: [ADMIN, MODERATOR])
"""
flag returns the counts for the flag action on an item. This edge is
restricted to administrators and moderators.
"""
flag: FlagActionCounts! @auth(roles: [ADMIN, MODERATOR])
}
"""
ActionPresence returns whether or not a given item has one of the following
actions on it. This is typically used to determine if a given user has left
one of the following actions.
"""
type ActionPresence {
"""
reaction is true when a reaction action was left on an item.
"""
reaction: Boolean!
"""
dontAgree is true when a dontAgree action was left on an item.
"""
dontAgree: Boolean!
"""
flag is true when a flag action was left on an item.
"""
flag: Boolean!
}
################################################################################
@@ -331,6 +489,42 @@ type Email {
fromAddress: String
}
################################################################################
## ReactionConfiguration
################################################################################
"""
ReactionConfiguration stores the configuration for reactions used across this
Tenant.
"""
type ReactionConfiguration {
"""
icon is the string representing the icon to be used for the reactions.
"""
icon: String!
"""
"""
iconActive: String
"""
label is the string placed beside the reaction icon to provide better context.
"""
label: String!
"""
labelActive is the string placed beside the reaction icon to provide better
context when it has been selected.
"""
labelActive: String
"""
color is the hex color code that can be used to change the color of the button.
"""
color: String
}
################################################################################
## Settings
################################################################################
@@ -439,22 +633,22 @@ type Settings {
"""
organizationName is the name of the organization.
"""
organizationName: String
organizationName: String!
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String
organizationContactEmail: String!
"""
email is the set of credentials and settings associated with the organization.
"""
email: Email @auth(roles: [ADMIN])
email: Email! @auth(roles: [ADMIN])
"""
wordlist will return a given list of words.
"""
wordlist: Wordlist @auth(roles: [ADMIN, MODERATOR])
wordlist: Wordlist! @auth(roles: [ADMIN, MODERATOR])
"""
auth contains all the settings related to authentication and authorization.
@@ -464,13 +658,18 @@ type Settings {
"""
integrations contains all the external integrations that can be enabled.
"""
integrations: ExternalIntegrations @auth(roles: [ADMIN])
integrations: ExternalIntegrations! @auth(roles: [ADMIN])
"""
karma is the set of settings related to how user Trust and Karma are
handled.
"""
karma: Karma @auth(roles: [ADMIN, MODERATOR])
karma: Karma! @auth(roles: [ADMIN, MODERATOR])
"""
reaction specifies the configuration for reactions.
"""
reaction: ReactionConfiguration!
}
################################################################################
@@ -697,6 +896,17 @@ type Comment {
"""
editing: EditInfo!
"""
actionCounts stores the counts of all the actions for the Comment.
"""
actionCounts: ActionCounts!
"""
myActionPresence stores the presense information for all the actions
left by the current User on this Comment.
"""
myActionPresence: ActionPresence
"""
asset is the Asset that the Comment was written on.
"""
@@ -838,6 +1048,12 @@ type Asset {
after: Cursor
): CommentsConnection!
"""
actionCounts stores the counts of all the actions against this Asset and it's
Comments.
"""
actionCounts: ActionCounts! @auth(roles: [ADMIN, MODERATOR])
"""
author is the authors listed in the meta tags for the Asset.
"""
@@ -1388,6 +1604,179 @@ type UpdateSettingsPayload {
clientMutationId: String!
}
##################
## createCommentReaction
##################
input CreateCommentReactionInput {
"""
commentID is the Comment's ID that we want to create a Reaction on.
"""
commentID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type CreateCommentReactionPayload {
"""
comment is the Comment that the Reaction was created on.
"""
comment: Comment
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## deleteCommentReaction
##################
input DeleteCommentReactionInput {
"""
commentID is the Comment's ID that we want to delete a Reaction on.
"""
commentID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type DeleteCommentReactionPayload {
"""
comment is the Comment that the Reaction was deleted on.
"""
comment: Comment
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## createCommentDontAgree
##################
input CreateCommentDontAgreeInput {
"""
commentID is the Comment's ID that we want to create a DontAgree on.
"""
commentID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type CreateCommentDontAgreePayload {
"""
comment is the Comment that the DontAgree was created on.
"""
comment: Comment
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## deleteCommentDontAgree
##################
input DeleteCommentDontAgreeInput {
"""
commentID is the Comment's ID that we want to delete a DontAgree on.
"""
commentID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type DeleteCommentDontAgreePayload {
"""
comment is the Comment that the DontAgree was deleted on.
"""
comment: Comment
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## createCommentFlag
##################
input CreateCommentFlagInput {
"""
commentID is the Comment's ID that we want to create a Flag on.
"""
commentID: ID!
"""
reason is the selected reason why the Flag is being created.
"""
reason: COMMENT_FLAG_REPORTED_REASON!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type CreateCommentFlagPayload {
"""
comment is the Comment that the Flag was created on.
"""
comment: Comment
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## deleteCommentFlag
##################
input DeleteCommentFlagInput {
"""
commentID is the Comment's ID that we want to delete a Flag on.
"""
commentID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type DeleteCommentFlagPayload {
"""
comment is the Comment that the Flag was deleted on.
"""
comment: Comment
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## Mutation
##################
@@ -1409,6 +1798,52 @@ type Mutation {
"""
updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload
@auth(roles: [ADMIN, MODERATOR])
"""
createCommentReaction will create a Reaction authored by the current logged in
User on a Comment.
"""
createCommentReaction(
input: CreateCommentReactionInput!
): CreateCommentReactionPayload @auth
"""
deleteCommentReaction will delete a Reaction authored by the current logged in
User on a Comment if it exists.
"""
deleteCommentReaction(
input: CreateCommentReactionInput!
): CreateCommentReactionPayload @auth
"""
createCommentDontAgree will create a DontAgree authored by the current logged in
User on a Comment.
"""
createCommentDontAgree(
input: CreateCommentDontAgreeInput!
): CreateCommentDontAgreePayload @auth
"""
deleteCommentDontAgree will delete a DontAgree authored by the current logged in
User on a Comment if it exists.
"""
deleteCommentDontAgree(
input: CreateCommentDontAgreeInput!
): CreateCommentDontAgreePayload @auth
"""
createCommentFlag will create a Flag authored by the current logged in User on
a given Comment.
"""
createCommentFlag(input: CreateCommentFlagInput!): CreateCommentFlagPayload
@auth
"""
deleteCommentFlag will create a Flag authored by the current logged in User on
a given Comment.
"""
deleteCommentFlag(input: DeleteCommentFlagInput!): DeleteCommentFlagPayload
@auth
}
################################################################################
@@ -0,0 +1,24 @@
import {
GQLCOMMENT_FLAG_REASON,
GQLFlagReasonActionCounts,
} from "talk-server/graph/tenant/schema/__generated__/types";
type ExtractKeys<T> = { [P in keyof T]: P }[keyof T];
type A = ExtractKeys<typeof GQLCOMMENT_FLAG_REASON>;
type B = ExtractKeys<GQLFlagReasonActionCounts>;
// These tests ensure that the enums contained in GQLCOMMENT_FLAG_REASON are
// also defined on the GQLFlagReasonActionCounts type.
describe("GQLFlagReasonActionCounts", () => {
it("contains all the flag enum types", () => {
const a: A = "" as any;
let b: B = "" as any;
b = a;
expect(b).toBe("");
let c: A = "" as any;
const d: B = "" as any;
c = d;
expect(c).toBe("");
});
});
@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#decodeActionCounts parses the action counts correctly 1`] = `
Object {
"DONT_AGREE": 1,
"FLAG": 2,
"FLAG__COMMENT_DETECTED_BANNED_WORD": 1,
"FLAG__COMMENT_DETECTED_BODY_COUNT": 1,
"REACTION": 3,
}
`;
exports[`#decodeActionCounts parses the action counts correctly 2`] = `
Object {
"dontAgree": Object {
"total": 1,
},
"flag": Object {
"reasons": Object {
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_BODY_COUNT": 1,
"COMMENT_DETECTED_LINKS": 0,
"COMMENT_DETECTED_SPAM": 0,
"COMMENT_DETECTED_SUSPECT_WORD": 0,
"COMMENT_DETECTED_TOXIC": 0,
"COMMENT_DETECTED_TRUST": 0,
"COMMENT_REPORTED_OFFENSIVE": 0,
"COMMENT_REPORTED_SPAM": 0,
},
"total": 2,
},
"reaction": Object {
"total": 3,
},
}
`;
exports[`#encodeActionCounts generates the action counts correctly 1`] = `
Object {
"DONT_AGREE": 1,
"FLAG": 2,
"FLAG__COMMENT_DETECTED_BANNED_WORD": 1,
"FLAG__COMMENT_DETECTED_BODY_COUNT": 1,
}
`;
+132
View File
@@ -0,0 +1,132 @@
import { GQLCOMMENT_FLAG_REASON } from "talk-server/graph/tenant/schema/__generated__/types";
import {
Action,
ACTION_ITEM_TYPE,
ACTION_TYPE,
decodeActionCounts,
encodeActionCounts,
validateAction,
} from "talk-server/models/action";
describe("#encodeActionCounts", () => {
it("generates the action counts correctly", () => {
const actions = [
{ action_type: ACTION_TYPE.DONT_AGREE },
{
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
{
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
];
const actionCounts = encodeActionCounts(...(actions as Action[]));
expect(actionCounts).toMatchSnapshot();
});
});
describe("#decodeActionCounts", () => {
it("parses the action counts correctly", () => {
const actions = [
{ action_type: ACTION_TYPE.REACTION },
{ action_type: ACTION_TYPE.REACTION },
{ action_type: ACTION_TYPE.REACTION },
{ action_type: ACTION_TYPE.DONT_AGREE },
{
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
{
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
];
const modelActionCounts = encodeActionCounts(...(actions as Action[]));
expect(modelActionCounts).toMatchSnapshot();
const actionCounts = decodeActionCounts(modelActionCounts);
expect(actionCounts).toMatchSnapshot();
});
});
describe("#validateAction", () => {
it("allows a valid action", () => {
const actions = [
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.REACTION,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.DONT_AGREE,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TRUST,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
},
];
for (const action of actions) {
validateAction(action as Action);
}
});
it("does not allow an invalid action", () => {
const actions = [
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.DONT_AGREE,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.DONT_AGREE,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
},
];
for (const action of actions) {
expect(() => validateAction(action as Action)).toThrow();
}
});
});
+511
View File
@@ -0,0 +1,511 @@
import Joi from "joi";
import { camelCase, pick } from "lodash";
import { Db } from "mongodb";
import uuid from "uuid";
import { Omit, Sub } from "talk-common/types";
import {
GQLActionCounts,
GQLActionPresence,
GQLCOMMENT_FLAG_DETECTED_REASON,
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_FLAG_REPORTED_REASON,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { FilterQuery } from "talk-server/models/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
return db.collection<Readonly<Action>>("actions");
}
export enum ACTION_TYPE {
/**
* REACTION corresponds to a reaction to a comment from a user.
*/
REACTION = "REACTION",
/**
* DONT_AGREE corresponds to when a user marks a given comment that they don't
* agree with.
*/
DONT_AGREE = "DONT_AGREE",
/**
* FLAG corresponds to a flag action that indicates that the given resource needs
* moderator attention.
*/
FLAG = "FLAG",
}
export type EncodedActionCounts = Record<string, number>;
export interface ActionCountGroup {
total: number;
}
export enum ACTION_ITEM_TYPE {
COMMENTS = "COMMENTS",
}
/**
* FLAG_REASON is the reason that a given Flag has been created.
*/
export type FLAG_REASON =
| GQLCOMMENT_FLAG_DETECTED_REASON
| GQLCOMMENT_FLAG_REPORTED_REASON
| GQLCOMMENT_FLAG_REASON;
export interface Action extends TenantResource {
readonly id: string;
action_type: ACTION_TYPE;
item_type: ACTION_ITEM_TYPE;
item_id: string;
reason?: FLAG_REASON;
user_id?: string;
created_at: Date;
metadata?: Record<string, any>;
}
const ActionSchema = [
// Flags
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
// Only reasons for the flag action will be allowed here, and it must be
// specified.
reason: Object.keys(GQLCOMMENT_FLAG_REASON),
},
// Don't Agree
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.DONT_AGREE,
},
// Reaction
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.REACTION,
},
];
/**
* validateAction is used to validate that a specific action conforms to the
* expected schema, `ActionSchema`.
*/
export function validateAction(
action: Pick<Action, "item_type" | "action_type" | "reason">
) {
const { error } = Joi.validate(
// In typescript, this isn't an issue, but when this is transpiled to
// javascript, it will contain additional elements.
pick(action, ["item_type", "action_type", "reason"]),
ActionSchema,
{
presence: "required",
abortEarly: false,
}
);
if (error) {
// TODO: wrap error?
throw error;
}
}
export type CreateActionInput = Omit<Action, "id" | "tenant_id" | "created_at">;
export interface CreateActionResultObject {
/**
* action contains the resultant action that was created.
*/
action: Action;
/**
* wasUpserted when true, indicates that this action was just newly created.
* When false, it indicates that this action was just looked up, and had
* existed prior to the `createAction` call.
*/
wasUpserted: boolean;
}
export async function createAction(
mongo: Db,
tenantID: string,
input: CreateActionInput
): Promise<CreateActionResultObject> {
// Create a new ID for the action.
const id = uuid.v4();
// defaults are the properties set by the application when a new action is
// created.
const defaults: Sub<Action, CreateActionInput> = {
id,
tenant_id: tenantID,
created_at: new Date(),
};
// Merge the defaults with the input.
const action: Readonly<Action> = {
...defaults,
...input,
};
// This filter ensures that a given user can't flag/respect a given user more
// than once.
const filter: FilterQuery<Action> = {
action_type: input.action_type,
item_type: input.item_type,
item_id: input.item_id,
reason: input.reason,
user_id: input.user_id,
};
// Create the upsert/update operation.
const update: { $setOnInsert: Readonly<Action> } = {
$setOnInsert: action,
};
// Insert the action into the database using an upsert operation.
const result = await collection(mongo).findOneAndUpdate(filter, update, {
// We are using this to create a action, so we need to upsert it.
upsert: true,
// False to return the updated document instead of the original document.
// This lets us detect if the document was updated or not.
returnOriginal: false,
});
// Check to see if this was a new action that was upserted, or one was found
// that matched existing records. We are sure here that the record exists
// because we're returning the updated document and performing an upsert
// operation.
// Because it's relevant that we know that the action was just created, or
// was just looked up, we need to return the action with an object that
// indicates if it was upserted.
const wasUpserted = result.value!.id === id;
// Return the action that was created/found with a boolean indicating if this
// action was just upserted (and therefore was newly created).
return {
action: result.value!,
wasUpserted,
};
}
export async function createActions(
mongo: Db,
tenantID: string,
inputs: CreateActionInput[]
): Promise<CreateActionResultObject[]> {
// TODO: (wyattjoh) replace with a batch write.
return Promise.all(inputs.map(input => createAction(mongo, tenantID, input)));
}
/**
* retrieveManyUserActionPresence returns the action presence for a specific
* user.
*/
export async function retrieveManyUserActionPresence(
mongo: Db,
tenantID: string,
userID: string | null,
itemType: ACTION_ITEM_TYPE,
itemIDs: string[]
): Promise<GQLActionPresence[]> {
const cursor = await collection(mongo).find(
{
tenant_id: tenantID,
user_id: userID,
item_type: itemType,
item_id: { $in: itemIDs },
},
{
// We only need the item_id and action_type from the database.
projection: {
item_id: 1,
action_type: 1,
},
}
);
const actions = await cursor.toArray();
// For each of the actions returned by the query, group the actions by the
// item id. Then compute the action presence for each of the actions.
return itemIDs
.map(itemID => actions.filter(action => action.item_id === itemID))
.map(itemActions =>
itemActions.reduce(
(actionPresence, { action_type }) => ({
...actionPresence,
[camelCase(action_type)]: true,
}),
{
reaction: false,
dontAgree: false,
flag: false,
}
)
);
}
export type DeleteActionInput = Pick<
Action,
"action_type" | "item_type" | "item_id" | "reason" | "user_id"
>;
/**
* The result returned by `deleteAction`.
*/
export interface DeletedActionResultObject {
/**
* action is the action that was deleted.
*/
action?: Action;
/**
* wasDeleted is true when the action that was supposed to be deleted was
* actually deleted.
*/
wasDeleted: boolean;
}
/**
* deleteAction will delete the action based on the form of the action rather
* than a specific action by ID.
*/
export async function deleteAction(
mongo: Db,
tenantID: string,
input: DeleteActionInput
): Promise<DeletedActionResultObject> {
// Extract the filter parameters.
const filter: FilterQuery<Action> = {
tenant_id: tenantID,
action_type: input.action_type,
item_type: input.item_type,
item_id: input.item_id,
user_id: input.user_id,
};
// Only add the reason to the filter if it's been specified, otherwise we'll
// never match a Flag that has an unspecified reason.
if (input.reason) {
filter.reason = input.reason;
}
// Remove the action from the database, returning the action that was deleted.
const result = await collection(mongo).findOneAndDelete(filter);
return {
action: result.value,
wasDeleted: Boolean(result.ok && result.value),
};
}
/**
* ACTION_COUNT_JOIN_CHAR is the character that is used to separate the reason
* from the action type when storing the action counts in the models.
*/
export const ACTION_COUNT_JOIN_CHAR = "__";
/**
* encodeActionCounts will take a list of actions, and generate action counts
* from it.
*
* @param actions list of actions to generate the action counts from
*/
export function encodeActionCounts(...actions: Action[]): EncodedActionCounts {
const actionCounts: EncodedActionCounts = {};
// Loop over the actions, and increment them.
for (const action of actions) {
for (const key of encodeActionCountKeys(action)) {
if (key in actionCounts) {
actionCounts[key]++;
} else {
actionCounts[key] = 1;
}
}
}
return actionCounts;
}
/**
* invertEncodedActionCounts will allow inverting of the action count object.
*
* @param actionCounts the encoded action counts to invert
*/
export function invertEncodedActionCounts(
actionCounts: EncodedActionCounts
): EncodedActionCounts {
for (const key in actionCounts) {
if (!actionCounts.hasOwnProperty(key)) {
continue;
}
if (actionCounts[key] > 0) {
actionCounts[key] = -actionCounts[key];
}
}
return actionCounts;
}
/**
* encodeActionCountKeys encodes the action into string keys which represents
* the groupings as seen in `EncodedActionCounts`.
*/
function encodeActionCountKeys(action: Action): string[] {
const keys = [action.action_type as string];
if (action.reason) {
keys.push(
[action.action_type as string, action.reason as string].join(
ACTION_COUNT_JOIN_CHAR
)
);
}
return keys;
}
interface DecodedActionCountKey {
/**
* actionType stores the action type referenced by the key.
*/
actionType: ACTION_TYPE;
/**
* reason stores the reason referenced by the key if the actionType is FLAG.
*/
reason?: GQLCOMMENT_FLAG_REASON;
}
/**
* decodeActionCountGroup will unpack the key as it is encoded into the separate
* actionType and reason.
*/
function decodeActionCountKey(key: string): DecodedActionCountKey {
let actionType: string = "";
let reason: string = "";
if (key.indexOf(ACTION_COUNT_JOIN_CHAR) >= 0) {
const keys = key.split(ACTION_COUNT_JOIN_CHAR);
if (keys.length !== 2) {
throw new Error(
"invalid action count contained more than two components"
);
}
actionType = keys[0];
reason = keys[1];
// Validate that the action type is flag.
if (actionType !== ACTION_TYPE.FLAG) {
throw new Error("invalid action type, expected only flag to have reason");
}
// Validate that the reason is valid.
if (!reason || !(reason in GQLCOMMENT_FLAG_REASON)) {
throw new Error("expected flag to have a reason that was valid");
}
} else {
actionType = key;
}
// Validate that the action type is valid.
if (!actionType || !(actionType in ACTION_TYPE)) {
throw new Error("expected action to have an action type that was valid");
}
const result: DecodedActionCountKey = {
actionType: actionType as ACTION_TYPE,
};
// Merge in the reason if it's provided. If we got here, we know that the
// reason is a GQLCOMMENT_FLAG_REASON.
if (reason) {
result.reason = reason as GQLCOMMENT_FLAG_REASON;
}
return result;
}
/**
* createEmptyActionCounts creates a default/empty set of action counts.
*/
function createEmptyActionCounts(): GQLActionCounts {
return {
reaction: {
total: 0,
},
dontAgree: {
total: 0,
},
flag: {
total: 0,
// Note that this isn't type checked.. We force it because TS can't
// understand the reduce.
reasons: Object.keys(GQLCOMMENT_FLAG_REASON).reduce(
(reasons, reason) => ({
...reasons,
[reason]: 0,
}),
{}
) as Record<GQLCOMMENT_FLAG_REASON, number>,
},
};
}
/**
* decodeActionCounts will take the encoded action counts and decode them into
* a useable format.
*
* @param encodedActionCounts the action counts to decode
*/
export function decodeActionCounts(
encodedActionCounts: EncodedActionCounts
): GQLActionCounts {
// Default all the action counts to zero.
const actionCounts: GQLActionCounts = createEmptyActionCounts();
// Loop over all the encoded action counts to extract each of the action
// counts as they are encoded.
Object.entries(encodedActionCounts).map(([key, count]) => {
// Pull out the action type and the reason from the key.
const { actionType, reason } = decodeActionCountKey(key);
// Handle the different types and reasons.
incrementActionCounts(actionCounts, actionType, reason, count);
});
return actionCounts;
}
function incrementActionCounts(
actionCounts: GQLActionCounts,
actionType: ACTION_TYPE,
reason: GQLCOMMENT_FLAG_REASON | undefined,
count: number = 1
) {
switch (actionType) {
case ACTION_TYPE.REACTION:
actionCounts.reaction.total += count;
break;
case ACTION_TYPE.DONT_AGREE:
actionCounts.dontAgree.total += count;
break;
case ACTION_TYPE.FLAG:
// When we have a reason, we are incrementing for that particular reason
// rather than incrementing for the total. If we don't have a reason, we
// just got the updated reason.
if (reason) {
actionCounts.flag.reasons[reason] += count;
} else {
actionCounts.flag.total += count;
}
break;
default:
throw new Error("unexpected action type");
}
return actionCounts;
}
-18
View File
@@ -1,18 +0,0 @@
import {
GQLACTION_GROUP,
GQLACTION_ITEM_TYPE,
GQLACTION_TYPE,
} from "talk-server/graph/tenant/schema/__generated__/types";
export type ActionCounts = Record<string, number>;
export interface Action {
readonly id: string;
action_type: GQLACTION_TYPE;
item_type: GQLACTION_ITEM_TYPE;
item_id: string;
group_id?: GQLACTION_GROUP;
user_id?: string;
created_at: Date;
metadata?: Record<string, any>;
}
+35
View File
@@ -4,6 +4,7 @@ import uuid from "uuid";
import { Omit } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { EncodedActionCounts } from "talk-server/models/action";
import { ModerationSettings } from "talk-server/models/settings";
import { TenantResource } from "talk-server/models/tenant";
@@ -37,6 +38,11 @@ export interface Asset extends TenantResource {
author?: string;
publication_date?: Date;
/**
* action_counts stores all the action counts for all Comment's on this Asset.
*/
action_counts: EncodedActionCounts;
/**
* comment_counts stores the different counts for each comment on the Asset
* according to their statuses.
@@ -72,6 +78,7 @@ export async function upsertAsset(
url,
tenant_id: tenantID,
created_at: now,
action_counts: {},
comment_counts: createEmptyCommentCounts(),
},
};
@@ -250,3 +257,31 @@ export async function updateAsset(
return result.value || null;
}
/**
* updateAssetActionCounts will update the given comment's action counts on
* the Asset.
*
* @param mongo the database handle
* @param tenantID the id of the Tenant
* @param id the id of the Asset being updated
* @param actionCounts the action counts to merge into the Asset
*/
export async function updateAssetActionCounts(
mongo: Db,
tenantID: string,
id: string,
actionCounts: EncodedActionCounts
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenant_id: tenantID },
// Update all the specific action counts that are associated with each of
// the counts.
{ $inc: dotize({ action_counts: actionCounts }) },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value;
}
+30 -3
View File
@@ -7,7 +7,7 @@ import {
GQLCOMMENT_SORT,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ActionCounts } from "talk-server/models/actions";
import { EncodedActionCounts } from "talk-server/models/action";
import {
Connection,
createConnection,
@@ -43,7 +43,7 @@ export interface Comment extends TenantResource {
body_history: BodyHistoryItem[];
status: GQLCOMMENT_STATUS;
status_history: StatusHistoryItem[];
action_counts: ActionCounts;
action_counts: EncodedActionCounts;
grandparent_ids: string[];
reply_ids: string[];
reply_count: number;
@@ -514,10 +514,37 @@ function applyInputToQuery(input: ConnectionInput, query: Query<Comment>) {
}
break;
case GQLCOMMENT_SORT.RESPECT_DESC:
query.orderBy({ "action_counts.respect": -1, created_at: -1 });
query.orderBy({ "action_counts.REACTION": -1, created_at: -1 });
if (input.after) {
query.after(input.after as number);
}
break;
}
}
/**
* updateCommentActionCounts will update the given comment's action counts.
*
* @param mongo the database handle
* @param tenantID the id of the Tenant
* @param id the id of the Comment being updated
* @param actionCounts the action counts to merge into the Comment
*/
export async function updateCommentActionCounts(
mongo: Db,
tenantID: string,
id: string,
actionCounts: EncodedActionCounts
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenant_id: tenantID },
// Update all the specific action counts that are associated with each of
// the counts.
{ $inc: dotize({ action_counts: actionCounts }) },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value;
}
+6
View File
@@ -5,6 +5,7 @@ import {
GQLExternalIntegrations,
GQLKarma,
GQLMODERATION_MODE,
GQLReactionConfiguration,
GQLWordlist,
} from "talk-server/graph/tenant/schema/__generated__/types";
@@ -101,4 +102,9 @@ export interface Settings extends ModerationSettings {
* Various integrations with external services.
*/
integrations: GQLExternalIntegrations;
/**
* reaction specifies the configuration for reactions.
*/
reaction: GQLReactionConfiguration;
}
+10 -3
View File
@@ -49,10 +49,10 @@ export type CreateTenantInput = Pick<
/**
* create will create a new Tenant.
*
* @param db the MongoDB connection used to create the tenant.
* @param mongo the MongoDB connection used to create the tenant.
* @param input the customizable parts of the Tenant available during creation
*/
export async function createTenant(db: Db, input: CreateTenantInput) {
export async function createTenant(mongo: Db, input: CreateTenantInput) {
const defaults: Sub<Tenant, CreateTenantInput> = {
// Create a new ID.
id: uuid.v4(),
@@ -117,6 +117,13 @@ export async function createTenant(db: Db, input: CreateTenantInput) {
enabled: false,
},
},
reaction: {
// By default, the standard reaction style will use the Respect with the
// handshake.
label: "Respect",
labelActive: "Respected",
icon: "thumb_up",
},
};
// Create the new Tenant by merging it together with the defaults.
@@ -126,7 +133,7 @@ export async function createTenant(db: Db, input: CreateTenantInput) {
};
// Insert the Tenant into the database.
await collection(db).insert(tenant);
await collection(mongo).insert(tenant);
return tenant;
}
+2 -2
View File
@@ -7,7 +7,7 @@ import {
GQLUSER_ROLE,
GQLUSER_USERNAME_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ActionCounts } from "talk-server/models/actions";
import { EncodedActionCounts } from "talk-server/models/action";
import { FilterQuery } from "talk-server/models/query";
import { TenantResource } from "talk-server/models/tenant";
@@ -70,7 +70,7 @@ export interface User extends TenantResource {
tokens: Token[];
role: GQLUSER_ROLE;
status: UserStatus;
action_counts: ActionCounts;
action_counts: EncodedActionCounts;
ignored_users: string[];
created_at: Date;
}
@@ -0,0 +1,229 @@
import { Db } from "mongodb";
import { GQLCOMMENT_FLAG_REPORTED_REASON } from "talk-server/graph/tenant/schema/__generated__/types";
import {
ACTION_ITEM_TYPE,
ACTION_TYPE,
CreateActionInput,
createActions,
deleteAction,
DeleteActionInput,
encodeActionCounts,
invertEncodedActionCounts,
} from "talk-server/models/action";
import { updateAssetActionCounts } from "talk-server/models/asset";
import {
retrieveComment,
updateCommentActionCounts,
} from "talk-server/models/comment";
import { Comment } from "talk-server/models/comment";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
export async function addCommentActions(
mongo: Db,
tenant: Tenant,
comment: Readonly<Comment>,
inputs: CreateActionInput[]
): Promise<Readonly<Comment>> {
// Create each of the actions, returning each of the action results.
const results = await createActions(mongo, tenant.id, inputs);
// Get the actions that were upserted, we only want to increment the action
// counts of actions that were just created.
const upsertedActions = results
.filter(({ wasUpserted }) => wasUpserted)
.map(({ action }) => action);
if (upsertedActions.length > 0) {
// Compute the action counts.
const actionCounts = encodeActionCounts(...upsertedActions);
// Update the comment action counts here.
const updatedComment = await updateCommentActionCounts(
mongo,
tenant.id,
comment.id,
actionCounts
);
// Update the Asset with the updated action counts.
await updateAssetActionCounts(
mongo,
tenant.id,
comment.asset_id,
actionCounts
);
// Check to see if there was an actual comment returned (there should
// have been, we just created it!).
if (!updatedComment) {
// TODO: (wyattjoh) return a better error.
throw new Error("could not update comment action counts");
}
return updatedComment;
}
return comment;
}
async function addCommentAction(
mongo: Db,
tenant: Tenant,
input: CreateActionInput
): Promise<Readonly<Comment>> {
const comment = await retrieveComment(mongo, tenant.id, input.item_id);
if (!comment) {
// TODO: replace to match error returned by the models/comments.ts
throw new Error("comment not found");
}
return addCommentActions(mongo, tenant, comment, [input]);
}
export async function removeCommentAction(
mongo: Db,
tenant: Tenant,
input: DeleteActionInput
): Promise<Readonly<Comment>> {
// Get the Comment that we are leaving the Action on.
const comment = await retrieveComment(mongo, tenant.id, input.item_id);
if (!comment) {
// TODO: replace to match error returned by the models/comments.ts
throw new Error("comment not found");
}
// Create each of the actions, returning each of the action results.
const { wasDeleted, action } = await deleteAction(mongo, tenant.id, input);
if (wasDeleted) {
// Compute the action counts, and invert them (because we're deleting an
// action).
const actionCounts = invertEncodedActionCounts(encodeActionCounts(action!));
// Update the comment action counts here.
const updatedComment = await updateCommentActionCounts(
mongo,
tenant.id,
comment.id,
actionCounts
);
// Update the Asset with the updated action counts.
await updateAssetActionCounts(
mongo,
tenant.id,
comment.asset_id,
actionCounts
);
// Check to see if there was an actual comment returned.
if (!updatedComment) {
// TODO: (wyattjoh) return a better error.
throw new Error("could not update comment action counts");
}
return updatedComment;
}
return comment;
}
export type CreateCommentReaction = Pick<CreateActionInput, "item_id">;
export async function createReaction(
mongo: Db,
tenant: Tenant,
author: User,
input: CreateCommentReaction
) {
return addCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.REACTION,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
});
}
export type DeleteCommentReaction = Pick<DeleteActionInput, "item_id">;
export async function deleteReaction(
mongo: Db,
tenant: Tenant,
author: User,
input: DeleteCommentReaction
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.REACTION,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
});
}
export type CreateCommentDontAgree = Pick<CreateActionInput, "item_id">;
export async function createDontAgree(
mongo: Db,
tenant: Tenant,
author: User,
input: CreateCommentDontAgree
) {
return addCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.DONT_AGREE,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
});
}
export type DeleteCommentDontAgree = Pick<DeleteActionInput, "item_id">;
export async function deleteDontAgree(
mongo: Db,
tenant: Tenant,
author: User,
input: DeleteCommentDontAgree
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.DONT_AGREE,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
});
}
export type CreateCommentFlag = Pick<CreateActionInput, "item_id"> & {
reason: GQLCOMMENT_FLAG_REPORTED_REASON;
};
export async function createFlag(
mongo: Db,
tenant: Tenant,
author: User,
input: CreateCommentFlag
) {
return addCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.FLAG,
reason: input.reason,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
});
}
export type DeleteCommentFlag = Pick<DeleteActionInput, "item_id">;
export async function deleteFlag(
mongo: Db,
tenant: Tenant,
author: User,
input: DeleteCommentFlag
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.FLAG,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
});
}
+46 -8
View File
@@ -1,6 +1,7 @@
import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import { ACTION_ITEM_TYPE, CreateActionInput } from "talk-server/models/action";
import {
retrieveAsset,
updateCommentStatusCount,
@@ -15,6 +16,7 @@ import {
} from "talk-server/models/comment";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { addCommentActions } from "talk-server/services/comments/actions";
import { processForModeration } from "talk-server/services/comments/moderation";
import { Request } from "talk-server/types/express";
@@ -59,7 +61,7 @@ export async function create(
}
// Run the comment through the moderation phases.
const { status, metadata } = await processForModeration({
const { actions, status, metadata } = await processForModeration({
asset,
tenant,
comment: input,
@@ -67,9 +69,8 @@ export async function create(
req,
});
// TODO: (wyattjoh) use the actions somehow.
const comment = await createComment(mongo, tenant.id, {
// Create the comment!
let comment = await createComment(mongo, tenant.id, {
...input,
status,
action_counts: {},
@@ -77,6 +78,23 @@ export async function create(
metadata,
});
if (actions.length > 0) {
// The actions coming from the moderation phases didn't know the item_id
// at the time, and we didn't want the repetitive nature of adding the
// item_type each time, so this mapping function adds them!
const inputs = actions.map(
(action): CreateActionInput => ({
...action,
item_id: comment.id,
item_type: ACTION_ITEM_TYPE.COMMENTS,
})
);
// Insert and handle creating the actions.
comment = await addCommentActions(mongo, tenant, comment, inputs);
// asse
}
if (input.parent_id) {
// Push the child's ID onto the parent.
await pushChildCommentIDOntoParent(
@@ -122,7 +140,7 @@ export async function edit(
}
// Run the comment through the moderation phases.
const { status, metadata } = await processForModeration({
const { status, metadata, actions } = await processForModeration({
asset,
tenant,
comment: input,
@@ -130,9 +148,7 @@ export async function edit(
req,
});
// TODO: (wyattjoh) use the actions somehow.
const editedComment = await editComment(mongo, tenant.id, {
let editedComment = await editComment(mongo, tenant.id, {
id: input.id,
author_id: author.id,
body: input.body,
@@ -146,6 +162,28 @@ export async function edit(
Date.now() - tenant.editCommentWindowLength
),
});
if (!comment) {
// TODO: replace to match error returned by the models/comments.ts
throw new Error("comment not found");
}
if (actions.length > 0) {
// The actions coming from the moderation phases didn't know the item_id
// at the time, and we didn't want the repetitive nature of adding the
// item_type each time, so this mapping function adds them!
const inputs = actions.map(
(action): CreateActionInput => ({
...action,
// Strict null check seems to have failed here... Null checking was done
// above where we errored if the comment was falsely.
item_id: comment!.id,
item_type: ACTION_ITEM_TYPE.COMMENTS,
})
);
// Insert and handle creating the actions.
editedComment = await addCommentActions(mongo, tenant, comment, inputs);
}
if (comment.status !== editedComment.status) {
// Increment the status count for the particular status on the Asset, and
@@ -1,8 +1,8 @@
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import {
compose,
ModerationPhaseContext,
@@ -51,12 +51,12 @@ describe("compose", () => {
const flags = [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.TOXIC_COMMENT,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.SPAM_COMMENT,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
];
@@ -71,8 +71,8 @@ describe("compose", () => {
() => ({
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.LINKS,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
},
],
}),
@@ -85,8 +85,8 @@ describe("compose", () => {
}
expect(final.actions).not.toContainEqual({
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.LINKS,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
});
});
@@ -1,6 +1,6 @@
import { Omit, Promiseable } from "talk-common/types";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { Action } from "talk-server/models/actions";
import { CreateActionInput } from "talk-server/models/action";
import { Asset } from "talk-server/models/asset";
import { Comment } from "talk-server/models/comment";
import { Tenant } from "talk-server/models/tenant";
@@ -9,14 +9,10 @@ import { Request } from "talk-server/types/express";
import { moderationPhases } from "./phases";
// TODO: (wyattjoh) move into actions module once we have action methods.
export type CreateAction = Omit<
Action,
"id" | "item_type" | "item_id" | "created_at"
>;
export type ModerationAction = Omit<CreateActionInput, "item_id" | "item_type">;
export interface PhaseResult {
actions: CreateAction[];
actions: ModerationAction[];
status: GQLCOMMENT_STATUS;
metadata: Record<string, any>;
}
@@ -2,10 +2,10 @@ import striptags from "striptags";
import { isNil } from "lodash";
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
@@ -50,8 +50,8 @@ export const commentLength: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.REJECTED,
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.BODY_COUNT,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
metadata: {
count: length,
},
@@ -1,8 +1,8 @@
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -33,8 +33,8 @@ export const karma: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.TRUST,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
metadata: {
trust: getCommentTrustScore(author),
},
@@ -2,10 +2,10 @@ import linkify from "linkify-it";
import tlds from "tlds";
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
@@ -39,8 +39,8 @@ export const links: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.LINKS,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
metadata: {
links: comment.body,
},
@@ -1,11 +1,11 @@
import { Client } from "akismet-api";
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { ACTION_TYPE } from "talk-server/models/action";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -110,8 +110,8 @@ export const spam: IntermediateModerationPhase = async ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.SPAM_COMMENT,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
],
metadata: {
@@ -4,12 +4,12 @@ import fetch from "node-fetch";
import { Omit } from "talk-common/types";
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
GQLPerspectiveExternalIntegration,
} from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { ACTION_TYPE } from "talk-server/models/action";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -103,8 +103,8 @@ export const toxic: IntermediateModerationPhase = async ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.TOXIC_COMMENT,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
],
metadata: {
@@ -1,8 +1,8 @@
import {
GQLACTION_GROUP,
GQLACTION_TYPE,
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -29,8 +29,8 @@ export const wordlist: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.REJECTED,
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.BANNED_WORD,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
],
};
@@ -46,8 +46,8 @@ export const wordlist: IntermediateModerationPhase = ({
return {
actions: [
{
action_type: GQLACTION_TYPE.FLAG,
group_id: GQLACTION_GROUP.SUSPECT_WORD,
action_type: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
},
],
};