diff --git a/src/core/client/auth/test/__snapshots__/navigation.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/navigation.spec.tsx.snap index c3c5fe4ed..47693769e 100644 --- a/src/core/client/auth/test/__snapshots__/navigation.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/navigation.spec.tsx.snap @@ -216,7 +216,7 @@ exports[`navigates to sign up form 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -239,7 +239,7 @@ exports[`navigates to sign up form 1`] = ` Password

Must be at least ⁨8⁩ characters

diff --git a/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap index 4b0ee2dc7..5491404be 100644 --- a/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap @@ -56,7 +56,7 @@ exports[`accepts correct password 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -92,7 +92,7 @@ exports[`accepts correct password 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -237,7 +237,7 @@ exports[`accepts correct password confirmation 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -273,7 +273,7 @@ exports[`accepts correct password confirmation 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -418,7 +418,7 @@ exports[`accepts valid email 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -454,7 +454,7 @@ exports[`accepts valid email 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -612,7 +612,7 @@ exports[`accepts valid username 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -635,7 +635,7 @@ exports[`accepts valid username 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -793,7 +793,7 @@ exports[`checks for invalid characters in username 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -829,7 +829,7 @@ exports[`checks for invalid characters in username 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -987,7 +987,7 @@ exports[`checks for invalid email 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1023,7 +1023,7 @@ exports[`checks for invalid email 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -1181,7 +1181,7 @@ exports[`checks for too long username 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1217,7 +1217,7 @@ exports[`checks for too long username 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -1375,7 +1375,7 @@ exports[`checks for too short password 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1411,7 +1411,7 @@ exports[`checks for too short password 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -1569,7 +1569,7 @@ exports[`checks for too short username 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1605,7 +1605,7 @@ exports[`checks for too short username 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -1763,7 +1763,7 @@ exports[`checks for wrong password confirmation 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1799,7 +1799,7 @@ exports[`checks for wrong password confirmation 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -1944,7 +1944,7 @@ exports[`renders sign up form 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -1967,7 +1967,7 @@ exports[`renders sign up form 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -2099,7 +2099,7 @@ exports[`shows error when submitting empty form 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -2135,7 +2135,7 @@ exports[`shows error when submitting empty form 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -2280,7 +2280,7 @@ exports[`shows server error 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -2303,7 +2303,7 @@ exports[`shows server error 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -2427,7 +2427,7 @@ exports[`shows server error 2`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -2450,7 +2450,7 @@ exports[`shows server error 2`] = ` Password

Must be at least ⁨8⁩ characters

@@ -2569,7 +2569,7 @@ exports[`submits form successfully 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -2592,7 +2592,7 @@ exports[`submits form successfully 1`] = ` Password

Must be at least ⁨8⁩ characters

@@ -2711,7 +2711,7 @@ exports[`submits form successfully 2`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

@@ -2734,7 +2734,7 @@ exports[`submits form successfully 2`] = ` Password

Must be at least ⁨8⁩ characters

diff --git a/src/core/client/framework/lib/bootstrap/createManaged.tsx b/src/core/client/framework/lib/bootstrap/createManaged.tsx index f2d86e7c8..bc1219427 100644 --- a/src/core/client/framework/lib/bootstrap/createManaged.tsx +++ b/src/core/client/framework/lib/bootstrap/createManaged.tsx @@ -53,7 +53,16 @@ interface CreateContextArguments { */ export const timeagoFormatter: Formatter = (value, unit, suffix) => { // We use 'in' instead of 'from now' for language consistency - const ourSuffix = suffix === "from now" ? "in" : suffix; + const ourSuffix = suffix === "from now" ? "noSuffix" : suffix; + + if (unit === "second" && suffix === "ago") { + return ( + + Just now + + ); + } + return ( { const props: PropTypesOf = { - id: "comment-id", author: { username: "Marvin", }, body: "Woof", createdAt: "1995-12-17T03:24:00.000Z", + topBarRight: "topBarRight", + footer: "footer", + showEditedMarker: true, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); diff --git a/src/core/client/stream/components/Comment/Comment.tsx b/src/core/client/stream/components/Comment/Comment.tsx index bd4fc17f7..dcf77308e 100644 --- a/src/core/client/stream/components/Comment/Comment.tsx +++ b/src/core/client/stream/components/Comment/Comment.tsx @@ -1,32 +1,46 @@ -import React, { ReactElement, StatelessComponent } from "react"; +import React, { StatelessComponent } from "react"; import { Flex } from "talk-ui/components"; import * as styles from "./Comment.css"; +import EditedMarker from "./EditedMarker"; import HTMLContent from "./HTMLContent"; import Timestamp from "./Timestamp"; -import TopBar from "./TopBar"; +import TopBarLeft from "./TopBarLeft"; import Username from "./Username"; export interface CommentProps { - id: string; className?: string; author: { username: string | null; } | null; body: string | null; createdAt: string; - footer?: ReactElement | Array>; + topBarRight?: React.ReactNode; + footer?: React.ReactNode; + showEditedMarker?: boolean; } const Comment: StatelessComponent = props => { return (
- - {props.author && - props.author.username && {props.author.username}} - {props.createdAt} - + + + {props.author && + props.author.username && ( + {props.author.username} + )} + + {props.createdAt} + {props.showEditedMarker && } + + + {props.topBarRight &&
{props.topBarRight}
} +
{props.body || ""} {props.footer} diff --git a/src/core/client/stream/components/Comment/EditedMarker.css b/src/core/client/stream/components/Comment/EditedMarker.css new file mode 100644 index 000000000..147c8656f --- /dev/null +++ b/src/core/client/stream/components/Comment/EditedMarker.css @@ -0,0 +1,3 @@ +.root { + composes: detail from "talk-ui/shared/typography.css"; +} diff --git a/src/core/client/stream/components/Comment/EditedMarker.spec.tsx b/src/core/client/stream/components/Comment/EditedMarker.spec.tsx new file mode 100644 index 000000000..4017b77d1 --- /dev/null +++ b/src/core/client/stream/components/Comment/EditedMarker.spec.tsx @@ -0,0 +1,9 @@ +import { shallow } from "enzyme"; +import React from "react"; + +import EditedMarker from "./EditedMarker"; + +it("renders correctly", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/core/client/stream/components/Comment/EditedMarker.tsx b/src/core/client/stream/components/Comment/EditedMarker.tsx new file mode 100644 index 000000000..a07d0f7f2 --- /dev/null +++ b/src/core/client/stream/components/Comment/EditedMarker.tsx @@ -0,0 +1,16 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; + +import styles from "./EditedMarker.css"; + +const EditedMarker: StatelessComponent = () => ( +
+ ( + + Edited + + ) +
+); + +export default EditedMarker; diff --git a/src/core/client/stream/components/Comment/HTMLContent.tsx b/src/core/client/stream/components/Comment/HTMLContent.tsx index b9d61c945..62bd62b78 100644 --- a/src/core/client/stream/components/Comment/HTMLContent.tsx +++ b/src/core/client/stream/components/Comment/HTMLContent.tsx @@ -1,22 +1,17 @@ -import React from "react"; - import dompurify from "dompurify"; +import React, { StatelessComponent } from "react"; + import styles from "./HTMLContent.css"; -interface ContentProps { +interface HTMLContentProps { children: string; } -class HTMLContent extends React.Component { - public render() { - const { children } = this.props; - return ( -
- ); - } -} +const HTMLContent: StatelessComponent = ({ children }) => ( +
+); export default HTMLContent; diff --git a/src/core/client/stream/components/Comment/IndentedComment.spec.tsx b/src/core/client/stream/components/Comment/IndentedComment.spec.tsx index c86a66ef2..be489a804 100644 --- a/src/core/client/stream/components/Comment/IndentedComment.spec.tsx +++ b/src/core/client/stream/components/Comment/IndentedComment.spec.tsx @@ -8,7 +8,6 @@ import IndentedComment from "./IndentedComment"; it("renders correctly", () => { const props: PropTypesOf = { indentLevel: 1, - id: "comment-id", author: { username: "Marvin", }, diff --git a/src/core/client/stream/components/Comment/TopBar.spec.tsx b/src/core/client/stream/components/Comment/TopBarLeft.spec.tsx similarity index 81% rename from src/core/client/stream/components/Comment/TopBar.spec.tsx rename to src/core/client/stream/components/Comment/TopBarLeft.spec.tsx index 5ee691f70..8769bb3dd 100644 --- a/src/core/client/stream/components/Comment/TopBar.spec.tsx +++ b/src/core/client/stream/components/Comment/TopBarLeft.spec.tsx @@ -4,10 +4,10 @@ import TestRenderer from "react-test-renderer"; import { PropTypesOf } from "talk-framework/types"; import { UIContext, UIContextProps } from "talk-ui/components"; -import TopBar from "./TopBar"; +import TopBarLeft from "./TopBarLeft"; it("renders correctly on small screens", () => { - const props: PropTypesOf = { + const props: PropTypesOf = { children:
Hello World
, }; @@ -19,14 +19,14 @@ it("renders correctly on small screens", () => { const testRenderer = TestRenderer.create( - + ); expect(testRenderer.toJSON()).toMatchSnapshot(); }); it("renders correctly on big screens", () => { - const props: PropTypesOf = { + const props: PropTypesOf = { children:
Hello World
, }; @@ -38,7 +38,7 @@ it("renders correctly on big screens", () => { const testRenderer = TestRenderer.create( - + ); expect(testRenderer.toJSON()).toMatchSnapshot(); diff --git a/src/core/client/stream/components/Comment/TopBar.tsx b/src/core/client/stream/components/Comment/TopBarLeft.tsx similarity index 81% rename from src/core/client/stream/components/Comment/TopBar.tsx rename to src/core/client/stream/components/Comment/TopBarLeft.tsx index eed8aea32..5f3862020 100644 --- a/src/core/client/stream/components/Comment/TopBar.tsx +++ b/src/core/client/stream/components/Comment/TopBarLeft.tsx @@ -4,12 +4,12 @@ import { StatelessComponent } from "react"; import { Flex, MatchMedia } from "talk-ui/components"; -export interface TopBarProps { +export interface TopBarLeftProps { className?: string; children: React.ReactNode; } -const TopBar: StatelessComponent = props => { +const TopBarLeft: StatelessComponent = props => { const rootClassName = cn(props.className); return ( @@ -27,4 +27,4 @@ const TopBar: StatelessComponent = props => { ); }; -export default TopBar; +export default TopBarLeft; diff --git a/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap b/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap index 246444f0d..64419b0fa 100644 --- a/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap +++ b/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap @@ -5,16 +5,30 @@ exports[`renders username and body 1`] = ` className="Comment-root" role="article" > - - - Marvin - - - 1995-12-17T03:24:00.000Z - - + + + Marvin + + + + 1995-12-17T03:24:00.000Z + + + + +
+ topBarRight +
+ Woof @@ -22,6 +36,8 @@ exports[`renders username and body 1`] = ` className="Comment-footer" direction="row" itemGutter="half" - /> + > + footer +
`; diff --git a/src/core/client/stream/components/Comment/__snapshots__/EditedMarker.spec.tsx.snap b/src/core/client/stream/components/Comment/__snapshots__/EditedMarker.spec.tsx.snap new file mode 100644 index 000000000..8b957f989 --- /dev/null +++ b/src/core/client/stream/components/Comment/__snapshots__/EditedMarker.spec.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +
+ ( + + + Edited + + + ) +
+`; diff --git a/src/core/client/stream/components/Comment/__snapshots__/IndentedComment.spec.tsx.snap b/src/core/client/stream/components/Comment/__snapshots__/IndentedComment.spec.tsx.snap index 91fb1ec96..daaa79aca 100644 --- a/src/core/client/stream/components/Comment/__snapshots__/IndentedComment.spec.tsx.snap +++ b/src/core/client/stream/components/Comment/__snapshots__/IndentedComment.spec.tsx.snap @@ -13,7 +13,6 @@ exports[`renders correctly 1`] = ` } body="Woof" createdAt="1995-12-17T03:24:00.000Z" - id="comment-id" /> `; diff --git a/src/core/client/stream/components/Comment/__snapshots__/TopBar.spec.tsx.snap b/src/core/client/stream/components/Comment/__snapshots__/TopBarLeft.spec.tsx.snap similarity index 100% rename from src/core/client/stream/components/Comment/__snapshots__/TopBar.spec.tsx.snap rename to src/core/client/stream/components/Comment/__snapshots__/TopBarLeft.spec.tsx.snap diff --git a/src/core/client/stream/components/Comment/index.ts b/src/core/client/stream/components/Comment/index.ts index abb1d79ae..56c2da651 100644 --- a/src/core/client/stream/components/Comment/index.ts +++ b/src/core/client/stream/components/Comment/index.ts @@ -1 +1,4 @@ export { default, default as IndentedComment } from "./IndentedComment"; +export { default as TopBarLeft } from "./TopBarLeft"; +export { default as Username } from "./Username"; +export { default as Timestamp } from "./Timestamp"; diff --git a/src/core/client/stream/components/EditCommentForm.tsx b/src/core/client/stream/components/EditCommentForm.tsx new file mode 100644 index 000000000..d7d79ddd8 --- /dev/null +++ b/src/core/client/stream/components/EditCommentForm.tsx @@ -0,0 +1,161 @@ +import { CoralRTE } from "@coralproject/rte"; +import { Localized } from "fluent-react/compat"; +import React, { + EventHandler, + MouseEvent, + Ref, + StatelessComponent, +} from "react"; +import { Field, Form } from "react-final-form"; + +import { OnSubmit } from "talk-framework/lib/form"; +import { required } from "talk-framework/lib/validation"; +import { + AriaInfo, + Button, + Flex, + HorizontalGutter, + Message, + MessageIcon, + RelativeTime, + Typography, + ValidationMessage, +} from "talk-ui/components"; + +import { Timestamp, TopBarLeft, Username } from "./Comment"; +import RTE from "./RTE"; + +interface FormProps { + body: string; +} + +export interface EditCommentFormProps { + id: string; + className?: string; + author: { + username: string | null; + } | null; + createdAt: string; + editableUntil: string; + onSubmit: OnSubmit; + onCancel?: EventHandler>; + onClose?: EventHandler>; + initialValues?: FormProps; + rteRef?: Ref; + expired?: boolean; +} + +const EditCommentForm: StatelessComponent = props => { + const inputID = `comments-editCommentForm-rte-${props.id}`; + return ( +
+ {({ handleSubmit, submitting, hasValidationErrors, pristine }) => ( + + +
+ + {props.author && + props.author.username && ( + {props.author.username} + )} + {props.createdAt} + +
+ + {({ input, meta }) => ( +
+ + + Edit comment + + + + input.onChange(html)} + value={input.value} + placeholder="Edit comment" + forwardRef={props.rteRef} + disabled={submitting || props.expired} + /> + + {meta.touched && + (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} +
+ )} +
+ {props.expired ? ( + + + Edit time has expired. You can no longer edit this comment. + Why not post another one? + + + ) : ( + + alarm + } + > + {"Edit: remaining"} + + + )} + + {props.expired ? ( + + + + ) : ( + <> + + + + + + + + )} + +
+
+ )} + + ); +}; + +export default EditCommentForm; diff --git a/src/core/client/stream/components/PoweredBy.tsx b/src/core/client/stream/components/PoweredBy.tsx index 2f1c036eb..b1ca618c4 100644 --- a/src/core/client/stream/components/PoweredBy.tsx +++ b/src/core/client/stream/components/PoweredBy.tsx @@ -14,11 +14,7 @@ const PoweredBy: StatelessComponent = props => { id="comments-poweredBy" logo={} > - + {"Powered by The Coral Project"} diff --git a/src/core/client/stream/components/__snapshots__/PoweredBy.spec.tsx.snap b/src/core/client/stream/components/__snapshots__/PoweredBy.spec.tsx.snap index 14ae7599d..fdee2029d 100644 --- a/src/core/client/stream/components/__snapshots__/PoweredBy.spec.tsx.snap +++ b/src/core/client/stream/components/__snapshots__/PoweredBy.spec.tsx.snap @@ -16,7 +16,7 @@ exports[`renders correctly 1`] = ` Powered by <logo>The Coral Project</logo> diff --git a/src/core/client/stream/containers/CommentContainer.spec.tsx b/src/core/client/stream/containers/CommentContainer.spec.tsx index bde3f9606..772a8df79 100644 --- a/src/core/client/stream/containers/CommentContainer.spec.tsx +++ b/src/core/client/stream/containers/CommentContainer.spec.tsx @@ -19,10 +19,15 @@ it("renders username and body", () => { comment: { id: "comment-id", author: { + id: "author-id", username: "Marvin", }, body: "Woof", createdAt: "1995-12-17T03:24:00.000Z", + editing: { + edited: false, + editableUntil: "1995-12-17T03:24:30.000Z", + }, pending: false, }, indentLevel: 1, @@ -42,10 +47,15 @@ it("renders body only", () => { comment: { id: "comment-id", author: { + id: "author-id", username: null, }, body: "Woof", createdAt: "1995-12-17T03:24:00.000Z", + editing: { + edited: false, + editableUntil: "1995-12-17T03:24:30.000Z", + }, pending: false, }, indentLevel: 1, diff --git a/src/core/client/stream/containers/CommentContainer.tsx b/src/core/client/stream/containers/CommentContainer.tsx index 15d9b3eda..1cd09fd2e 100644 --- a/src/core/client/stream/containers/CommentContainer.tsx +++ b/src/core/client/stream/containers/CommentContainer.tsx @@ -1,6 +1,8 @@ +import { Localized } from "fluent-react/compat"; import React, { Component } from "react"; import { graphql } from "react-relay"; +import { isBeforeDate } from "talk-common/utils"; import withFragmentContainer from "talk-framework/lib/relay/withFragmentContainer"; import { PropTypesOf } from "talk-framework/types"; import { CommentContainer_asset as AssetData } from "talk-stream/__generated__/CommentContainer_asset.graphql"; @@ -11,10 +13,12 @@ import { withShowAuthPopupMutation, } from "talk-stream/mutations"; +import { Button } from "talk-ui/components"; import Comment from "../components/Comment"; import ReplyButton from "../components/Comment/ReplyButton"; -import ReplyCommentFormContainer from ".//ReplyCommentFormContainer"; +import EditCommentFormContainer from "./EditCommentFormContainer"; import PermalinkButtonContainer from "./PermalinkButtonContainer"; +import ReplyCommentFormContainer from "./ReplyCommentFormContainer"; interface InnerProps { me: MeData | null; @@ -26,13 +30,41 @@ interface InnerProps { interface State { showReplyDialog: boolean; + showEditDialog: boolean; + editable: boolean; } export class CommentContainer extends Component { + private uneditableTimer: any; + public state = { showReplyDialog: false, + showEditDialog: false, + editable: this.isEditable(), }; + constructor(props: InnerProps) { + super(props); + if (this.isEditable()) { + this.uneditableTimer = this.updateWhenNotEditable(); + } + } + + public componentWillUnmount() { + clearTimeout(this.uneditableTimer); + } + + private isEditable() { + const isMyComment = !!( + this.props.me && + this.props.comment.author && + this.props.me.id === this.props.comment.author.id + ); + return ( + isMyComment && isBeforeDate(this.props.comment.editing.editableUntil) + ); + } + private openReplyDialog = () => { if (this.props.me) { this.setState(state => ({ @@ -43,21 +75,72 @@ export class CommentContainer extends Component { } }; + private openEditDialog = () => { + if (this.props.me) { + this.setState(state => ({ + showEditDialog: true, + })); + } else { + this.props.showAuthPopup({ view: "SIGN_IN" }); + } + }; + + private closeEditDialog = () => { + this.setState(state => ({ + showEditDialog: false, + })); + }; + private closeReplyDialog = () => { this.setState(state => ({ showReplyDialog: false, })); }; + private updateWhenNotEditable() { + const ms = + new Date(this.props.comment.editing.editableUntil).getTime() - Date.now(); + if (ms > 0) { + return setTimeout(() => this.setState({ editable: false }), ms); + } + return; + } + public render() { - const { comment, asset, ...rest } = this.props; - const { showReplyDialog } = this.state; + const { comment, asset, indentLevel } = this.props; + const { showReplyDialog, showEditDialog, editable } = this.state; + if (showEditDialog) { + return ( + + ); + } return ( <> + + + )) || + undefined + } footer={ <> ({ me: graphql` fragment CommentContainer_me on User { - __typename + id } `, asset: graphql` @@ -97,12 +180,18 @@ const enhanced = withShowAuthPopupMutation( fragment CommentContainer_comment on Comment { id author { + id username } body createdAt + editing { + edited + editableUntil + } pending ...ReplyCommentFormContainer_comment + ...EditCommentFormContainer_comment } `, })(CommentContainer) diff --git a/src/core/client/stream/containers/EditCommentFormContainer.tsx b/src/core/client/stream/containers/EditCommentFormContainer.tsx new file mode 100644 index 000000000..9dee698d3 --- /dev/null +++ b/src/core/client/stream/containers/EditCommentFormContainer.tsx @@ -0,0 +1,131 @@ +import { CoralRTE } from "@coralproject/rte"; +import React, { Component } from "react"; +import { graphql } from "react-relay"; + +import { isBeforeDate } from "talk-common/utils"; +import { withContext } from "talk-framework/lib/bootstrap"; +import { BadUserInputError } from "talk-framework/lib/errors"; +import { withFragmentContainer } from "talk-framework/lib/relay"; +import { PropTypesOf } from "talk-framework/types"; + +import { EditCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/EditCommentFormContainer_comment.graphql"; + +import EditCommentForm, { + EditCommentFormProps, +} from "../components/EditCommentForm"; +import { EditCommentMutation, withEditCommentMutation } from "../mutations"; + +interface InnerProps { + editComment: EditCommentMutation; + comment: CommentData; + onClose?: () => void; + autofocus: boolean; +} + +interface State { + initialValues?: EditCommentFormProps["initialValues"]; + initialized: boolean; + expired: boolean; +} + +export class EditCommentFormContainer extends Component { + private expiredTimer: any; + + public state: State = { + initialized: false, + expired: !isBeforeDate(this.props.comment.editing.editableUntil), + }; + + constructor(props: InnerProps) { + super(props); + this.expiredTimer = this.updateWhenExpired(); + } + + public componentWillUnmount() { + clearTimeout(this.expiredTimer); + } + + private updateWhenExpired() { + const ms = + new Date(this.props.comment.editing.editableUntil).getTime() - Date.now(); + if (ms > 0) { + return setTimeout(() => this.setState({ expired: true }), ms); + } + return; + } + + private handleRTERef = (rte: CoralRTE | null) => { + if (rte && this.props.autofocus) { + rte.focus(); + } + }; + + private handleOnCancelOrClose = () => { + if (this.props.onClose) { + this.props.onClose(); + } + }; + + private handleOnSubmit: EditCommentFormProps["onSubmit"] = async ( + input, + form + ) => { + try { + await this.props.editComment({ + commentID: this.props.comment.id, + body: input.body, + }); + if (this.props.onClose) { + this.props.onClose(); + } + } catch (error) { + if (error instanceof BadUserInputError) { + return error.invalidArgsLocalized; + } + // tslint:disable-next-line:no-console + console.error(error); + } + return undefined; + }; + + public render() { + return ( + + ); + } +} +const enhanced = withContext(({ sessionStorage, browserInfo }) => ({ + // Disable autofocus on ios and enable for the rest. + autofocus: !browserInfo.ios, +}))( + withEditCommentMutation( + withFragmentContainer({ + comment: graphql` + fragment EditCommentFormContainer_comment on Comment { + id + body + createdAt + author { + username + } + editing { + editableUntil + } + } + `, + })(EditCommentFormContainer) + ) +); +export type PostCommentFormContainerProps = PropTypesOf; +export default enhanced; diff --git a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap index 6f2005410..f6e7f50ba 100644 --- a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap @@ -5,6 +5,7 @@ exports[`renders body only 1`] = ` } - id="comment-id" indentLevel={1} - me={null} - pending={false} - showAuthPopup={[Function]} + showEditedMarker={false} /> `; @@ -37,6 +35,7 @@ exports[`renders username and body 1`] = ` } - id="comment-id" indentLevel={1} - me={null} - pending={false} - showAuthPopup={[Function]} + showEditedMarker={false} /> `; diff --git a/src/core/client/stream/mutations/CreateCommentMutation.ts b/src/core/client/stream/mutations/CreateCommentMutation.ts index 2d173bd07..b858f04e2 100644 --- a/src/core/client/stream/mutations/CreateCommentMutation.ts +++ b/src/core/client/stream/mutations/CreateCommentMutation.ts @@ -93,6 +93,9 @@ function commit( username: me.username, }, body: input.body, + editing: { + editableUntil: new Date(Date.now() + 10000), + }, }, }, clientMutationId: (clientMutationId++).toString(), diff --git a/src/core/client/stream/mutations/EditCommentMutation.ts b/src/core/client/stream/mutations/EditCommentMutation.ts new file mode 100644 index 000000000..3eee01303 --- /dev/null +++ b/src/core/client/stream/mutations/EditCommentMutation.ts @@ -0,0 +1,62 @@ +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 { EditCommentMutation as MutationTypes } from "talk-stream/__generated__/EditCommentMutation.graphql"; + +export type EditCommentInput = Omit< + MutationTypes["variables"]["input"], + "clientMutationId" +>; + +const mutation = graphql` + mutation EditCommentMutation($input: EditCommentInput!) { + editComment(input: $input) { + comment { + body + editing { + edited + } + } + clientMutationId + } + } +`; + +let clientMutationId = 0; + +function commit(environment: Environment, input: EditCommentInput) { + return commitMutationPromiseNormalized(environment, { + mutation, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + optimisticResponse: { + editComment: { + id: input.commentID, + body: input.body, + editing: { + edited: true, + }, + clientMutationId: (clientMutationId++).toString(), + }, + } as any, // TODO: (cvle) generated types should contain one for the optimistic response. + }); +} + +export const withEditCommentMutation = createMutationContainer( + "editComment", + commit +); + +export type EditCommentMutation = ( + input: EditCommentInput +) => Promise; diff --git a/src/core/client/stream/mutations/index.ts b/src/core/client/stream/mutations/index.ts index 324fddbdc..6c920dfea 100644 --- a/src/core/client/stream/mutations/index.ts +++ b/src/core/client/stream/mutations/index.ts @@ -3,6 +3,11 @@ export { CreateCommentMutation, CreateCommentInput, } from "./CreateCommentMutation"; +export { + withEditCommentMutation, + EditCommentMutation, + EditCommentInput, +} from "./EditCommentMutation"; export { withSetNetworkStatusMutation, SetNetworkStatusMutation, diff --git a/src/core/client/stream/test/__snapshots__/editComment.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/editComment.spec.tsx.snap new file mode 100644 index 000000000..6a91b49e0 --- /dev/null +++ b/src/core/client/stream/test/__snapshots__/editComment.spec.tsx.snap @@ -0,0 +1,2584 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`cancel edit: edit canceled 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`edit a comment: edit form 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + + Edit: + + remaining + +
+
+ + +
+
+ +
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`edit a comment: optimistic response 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + + Edit: + + remaining + +
+
+ + +
+
+ +
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`edit a comment: render stream 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`edit a comment: server response 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + +
+ +
+ ( + + Edited + + ) +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`shows expiry message: edit form closed 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`shows expiry message: edit time expired 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + Edit time has expired. You can no longer edit this comment. Why not post another one? +
+
+ +
+
+ +
+
+
+
+
+
+ + Lukas + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+`; diff --git a/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap index 1d82d7bb9..84c4e89de 100644 --- a/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap @@ -156,20 +156,28 @@ exports[`loads more comments 1`] = ` role="article" >
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Isabelle - - + + Isabelle + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
+
+ +
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
+
+ +
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
Powered by
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Markus - - + + Markus + +
+ +
+
- - Lukas - - + + Lukas + +
+ +
+
- - Isabelle - - + + Isabelle + +
+ +
+
s.throws(), + s => s.withArgs(undefined, { id: assets[0].id }).returns(assets[0]) + ), + me: createSinonStub( + s => s.throws(), + s => s.withArgs(undefined).returns(users[0]) + ), + }, + Mutation: { + editComment: createSinonStub( + s => s.throws(), + s => + s + .withArgs(undefined, { + input: { + commentID: assets[0].comments.edges[0].node.id, + body: "Edited!", + clientMutationId: "0", + }, + }) + .returns({ + // TODO: add a type assertion here to ensure that if the type changes, that the test will fail + comment: { + id: assets[0].comments.edges[0].node.id, + body: "Edited! (from server)", + editing: { + edited: true, + }, + }, + clientMutationId: "0", + }) + ), + }, + }; + + const { testRenderer } = create({ + // Set this to true, to see graphql responses. + logNetwork: false, + resolvers, + initLocalState: localRecord => { + localRecord.setValue(assets[0].id, "assetID"); + }, + }); + return testRenderer; +} + +afterAll(() => { + timekeeper.reset(); +}); + +it("edit a comment", async () => { + timekeeper.freeze(assets[0].comments.edges[0].node.createdAt); + const testRenderer = createTestRenderer(); + + // Wait for loading. + await timeout(); + expect(testRenderer.toJSON()).toMatchSnapshot("render stream"); + + // Open edit form. + testRenderer.root + .findByProps({ id: "comments-commentContainer-editButton-comment-0" }) + .props.onClick(); + expect(testRenderer.toJSON()).toMatchSnapshot("edit form"); + + testRenderer.root + .findByProps({ inputId: "comments-editCommentForm-rte-comment-0" }) + .props.onChange({ html: "Edited!" }); + + testRenderer.root + .findByProps({ id: "comments-editCommentForm-form-comment-0" }) + .props.onSubmit(); + + // Test optimistic response. + expect(testRenderer.toJSON()).toMatchSnapshot("optimistic response"); + + // Wait for loading. + await timeout(); + + // Test after server response. + expect(testRenderer.toJSON()).toMatchSnapshot("server response"); +}); + +it("cancel edit", async () => { + timekeeper.freeze(assets[0].comments.edges[0].node.createdAt); + const testRenderer = createTestRenderer(); + + await timeout(); + + // Open edit form. + testRenderer.root + .findByProps({ id: "comments-commentContainer-editButton-comment-0" }) + .props.onClick(); + + // Cacnel edit form. + testRenderer.root + .findByProps({ id: "comments-editCommentForm-cancelButton-comment-0" }) + .props.onClick(); + expect(testRenderer.toJSON()).toMatchSnapshot("edit canceled"); +}); + +it("shows expiry message", async () => { + timekeeper.freeze(assets[0].comments.edges[0].node.createdAt); + const testRenderer = createTestRenderer(); + + await timeout(); + jest.useFakeTimers(); + // Open edit form. + testRenderer.root + .findByProps({ id: "comments-commentContainer-editButton-comment-0" }) + .props.onClick(); + + timekeeper.reset(); + jest.runOnlyPendingTimers(); + + // Show edit time expired. + expect(testRenderer.toJSON()).toMatchSnapshot("edit time expired"); + + // Close edit form. + testRenderer.root + .findByProps({ id: "comments-editCommentForm-closeButton-comment-0" }) + .props.onClick(); + expect(testRenderer.toJSON()).toMatchSnapshot("edit form closed"); +}); diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index b4e207978..ec219fb67 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -20,6 +20,10 @@ export const comments = [ body: "Joining Too", createdAt: "2018-07-06T18:24:00.000Z", replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, + editing: { + edited: false, + editableUntil: "2018-07-06T18:24:30.000Z", + }, }, { id: "comment-1", @@ -27,6 +31,10 @@ export const comments = [ body: "What's up?", createdAt: "2018-07-06T18:20:00.000Z", replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, + editing: { + edited: false, + editableUntil: "2018-07-06T18:20:30.000Z", + }, }, { id: "comment-2", @@ -34,6 +42,10 @@ export const comments = [ body: "Hey!", createdAt: "2018-07-06T18:14:00.000Z", replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, + editing: { + edited: false, + editableUntil: "2018-07-06T18:14:30.000Z", + }, }, ]; @@ -68,6 +80,10 @@ export const commentWithReplies = { hasNextPage: false, }, }, + editing: { + edited: false, + editableUntil: "2018-07-06T18:24:30.000Z", + }, }; export const assetWithReplies = { diff --git a/src/core/client/stream/test/postComment.spec.tsx b/src/core/client/stream/test/postComment.spec.tsx index 78120589d..8703cc757 100644 --- a/src/core/client/stream/test/postComment.spec.tsx +++ b/src/core/client/stream/test/postComment.spec.tsx @@ -35,6 +35,10 @@ beforeEach(() => { author: users[0], body: "Hello world! (from server)", createdAt: "2018-07-06T18:24:00.000Z", + editing: { + edited: false, + editableUntil: "2018-07-06T18:24:30.000Z", + }, }, }, clientMutationId: "0", diff --git a/src/core/client/stream/test/postReply.spec.tsx b/src/core/client/stream/test/postReply.spec.tsx index dd7f0eb6e..b0f8239a0 100644 --- a/src/core/client/stream/test/postReply.spec.tsx +++ b/src/core/client/stream/test/postReply.spec.tsx @@ -39,6 +39,10 @@ beforeEach(() => { edges: [], pageInfo: { endCursor: null, hasNextPage: false }, }, + editing: { + edited: false, + editableUntil: "2018-07-06T18:24:30.000Z", + }, }, }, clientMutationId: "0", diff --git a/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap b/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap index 874adc015..011020568 100644 --- a/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap +++ b/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap @@ -18,7 +18,7 @@ exports[`works with multiple form components 1`] = ` Username

A unique identifier displayed on your comments. You may use “_” and “.”

diff --git a/src/core/client/ui/components/InputDescription/InputDescription.tsx b/src/core/client/ui/components/InputDescription/InputDescription.tsx index d36d5fe7a..ae84ed141 100644 --- a/src/core/client/ui/components/InputDescription/InputDescription.tsx +++ b/src/core/client/ui/components/InputDescription/InputDescription.tsx @@ -11,7 +11,7 @@ const InputDescription: StatelessComponent = props => { const { className, children, ...rest } = props; return ( Form Components should go here

diff --git a/src/core/client/ui/components/Message/MessageIcon.css b/src/core/client/ui/components/Message/MessageIcon.css index df730b2de..0104b6cd1 100644 --- a/src/core/client/ui/components/Message/MessageIcon.css +++ b/src/core/client/ui/components/Message/MessageIcon.css @@ -1,4 +1,6 @@ .root { + align-self: flex-start; + margin-top: 1px; &:first-child { margin-right: calc(0.5 * var(--spacing-unit)); } diff --git a/src/core/client/ui/components/Message/index.ts b/src/core/client/ui/components/Message/index.ts index a6d22dd86..84f6b9009 100644 --- a/src/core/client/ui/components/Message/index.ts +++ b/src/core/client/ui/components/Message/index.ts @@ -1 +1,2 @@ -export { default } from "./Message"; +export { default, default as Message } from "./Message"; +export { default as MessageIcon } from "./MessageIcon"; diff --git a/src/core/client/ui/components/RelativeTime/RelativeTime.css b/src/core/client/ui/components/RelativeTime/RelativeTime.css index e0450679f..c3a2af639 100644 --- a/src/core/client/ui/components/RelativeTime/RelativeTime.css +++ b/src/core/client/ui/components/RelativeTime/RelativeTime.css @@ -1,4 +1,2 @@ .root { - composes: bodyCopy from "talk-ui/shared/typography.css"; - background-color: transparent; } diff --git a/src/core/client/ui/components/Typography/Typography.css b/src/core/client/ui/components/Typography/Typography.css index a2bd115fe..be3e0954e 100644 --- a/src/core/client/ui/components/Typography/Typography.css +++ b/src/core/client/ui/components/Typography/Typography.css @@ -40,8 +40,8 @@ composes: inputLabel from "talk-ui/shared/typography.css"; } -.inputDescription { - composes: inputDescription from "talk-ui/shared/typography.css"; +.detail { + composes: detail from "talk-ui/shared/typography.css"; } .timestamp { diff --git a/src/core/client/ui/components/Typography/Typography.tsx b/src/core/client/ui/components/Typography/Typography.tsx index 2c4a20d94..b8f6d2396 100644 --- a/src/core/client/ui/components/Typography/Typography.tsx +++ b/src/core/client/ui/components/Typography/Typography.tsx @@ -15,7 +15,7 @@ type Variant = | "bodyCopy" | "bodyCopyBold" | "inputLabel" - | "inputDescription" + | "detail" | "timestamp"; // Based on Typography Component of Material UI. @@ -146,7 +146,7 @@ Typography.defaultProps = { bodyCopyBold: "p", timestamp: "span", inputLabel: "label", - inputDescription: "p", + detail: "p", }, noWrap: false, paragraph: false, diff --git a/src/core/client/ui/components/index.ts b/src/core/client/ui/components/index.ts index 78261d408..7f88b7fab 100644 --- a/src/core/client/ui/components/index.ts +++ b/src/core/client/ui/components/index.ts @@ -22,3 +22,4 @@ export { default as Icon } from "./Icon"; export { default as AriaInfo } from "./AriaInfo"; export { default as Message } from "./Message"; export { Tab, TabBar, TabContent, TabPane } from "./Tabs"; +export { default as Message, MessageIcon } from "./Message"; diff --git a/src/core/client/ui/shared/typography.css b/src/core/client/ui/shared/typography.css index cb785f8fc..3012a3617 100644 --- a/src/core/client/ui/shared/typography.css +++ b/src/core/client/ui/shared/typography.css @@ -155,7 +155,7 @@ color: var(--palette-text-primary); } -.inputDescription { +.detail { font-size: calc(14rem / var(--rem-base)); font-weight: var(--font-weight-regular); font-family: var(--font-family-sans-serif); diff --git a/src/core/common/utils/index.ts b/src/core/common/utils/index.ts index 28435047e..4d6df9fc5 100644 --- a/src/core/common/utils/index.ts +++ b/src/core/common/utils/index.ts @@ -2,3 +2,4 @@ export { default as timeout } from "./timeout"; export { default as animationFrame } from "./animationFrame"; export { default as pascalCase } from "./pascalCase"; export { default as oncePerFrame } from "./oncePerFrame"; +export { default as isBeforeDate } from "./isBeforeDate"; diff --git a/src/core/common/utils/isBeforeDate.spec.ts b/src/core/common/utils/isBeforeDate.spec.ts new file mode 100644 index 000000000..4359ca55a --- /dev/null +++ b/src/core/common/utils/isBeforeDate.spec.ts @@ -0,0 +1,9 @@ +import timekeeper from "timekeeper"; +import isBeforeDate from "./isBeforeDate"; + +it("works correctly", () => { + timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z")); + expect(isBeforeDate(new Date("2018-07-06T18:24:30.000Z"))).toBe(true); + expect(isBeforeDate(new Date("2018-07-06T18:23:30.000Z"))).toBe(false); + timekeeper.reset(); +}); diff --git a/src/core/common/utils/isBeforeDate.ts b/src/core/common/utils/isBeforeDate.ts new file mode 100644 index 000000000..24d2a33e9 --- /dev/null +++ b/src/core/common/utils/isBeforeDate.ts @@ -0,0 +1,3 @@ +export default function isBeforeDate(date: string | number | Date) { + return new Date() < new Date(date); +} diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index d0c26e431..aeaac70ee 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -26,7 +26,6 @@ export default (ctx: TenantContext) => ({ ctx.user!, { id: input.commentID, - asset_id: input.assetID, body: input.body, }, ctx.req diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 3dcdb0894..9b808db0d 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -875,11 +875,6 @@ type CreateCommentPayload { EditCommentInput provides the input for the editComment Mutation. """ input EditCommentInput { - """ - assetID is the ID of the Asset where we are editing a comment on. - """ - assetID: ID! - """ commentID is the ID of the comment being edited. """ diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index d2c8e03a6..bec7cc382 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -2,6 +2,7 @@ import { Db } from "mongodb"; import uuid from "uuid"; import { Omit, Sub } from "talk-common/types"; +import { dotize } from "talk-common/utils/dotize"; import { GQLCOMMENT_SORT, GQLCOMMENT_STATUS, @@ -103,7 +104,7 @@ export async function createComment( export type EditCommentInput = Pick< Comment, - "id" | "author_id" | "body" | "status" + "id" | "author_id" | "body" | "status" | "metadata" > & { /** * lastEditableCommentCreatedAt is the date that the last comment would have @@ -125,7 +126,16 @@ export async function editComment( ]; const createdAt = new Date(); - const { id, body, lastEditableCommentCreatedAt, status, author_id } = input; + const { + id, + body, + lastEditableCommentCreatedAt, + status, + author_id, + metadata, + } = input; + + // TODO: (wyattjoh) consider resetting the action counts if we're starting fresh with a new comment const result = await collection(db).findOneAndUpdate( { @@ -144,6 +154,10 @@ export async function editComment( $set: { body, status, + // Embed all the metadata properties, this may override the existing + // metadata, but we won't replace metadata that has been recalculated. + // TODO: (wyattjoh) consider if we want to replace the metadata for edited comments instead of supplementing it + ...dotize({ metadata }), }, $push: { body_history: { diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index b356b919b..25a264f5a 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -74,12 +74,7 @@ export async function create( export type EditComment = Omit< EditCommentInput, "status" | "author_id" | "lastEditableCommentCreatedAt" -> & { - /** - * asset_id is the asset that the comment exists on. - */ - asset_id: string; -}; +>; export async function edit( mongo: Db, @@ -88,15 +83,22 @@ export async function edit( input: EditComment, req?: Request ) { + // Get the comment that we're editing. + let comment = await retrieveComment(mongo, tenant.id, input.id); + if (!comment) { + // TODO: replace to match error returned by the models/comments.ts + throw new Error("comment not found"); + } + // Grab the asset that we'll use to check moderation pieces with. - const asset = await retrieveAsset(mongo, tenant.id, input.asset_id); + const asset = await retrieveAsset(mongo, tenant.id, comment.asset_id); if (!asset) { // TODO: (wyattjoh) return better error. throw new Error("asset referenced does not exist"); } // Run the comment through the moderation phases. - const { status } = await processForModeration({ + const { status, metadata } = await processForModeration({ asset, tenant, comment: input, @@ -106,11 +108,12 @@ export async function edit( // TODO: (wyattjoh) use the actions somehow. - const comment = await editComment(mongo, tenant.id, { + comment = await editComment(mongo, tenant.id, { id: input.id, author_id: author.id, body: input.body, status, + metadata, // The editable time is based on the current time, and the edit window // length. By subtracting the current date from the edit window length, we // get the maximum value for the `created_at` time that would be permitted diff --git a/src/locales/de/framework.ftl b/src/locales/de/framework.ftl index 55dca1c8c..7ea668be3 100644 --- a/src/locales/de/framework.ftl +++ b/src/locales/de/framework.ftl @@ -6,10 +6,10 @@ framework-validation-required = Dies ist ein Pflichtpfeld. +framework-timeago-just-now = Gerade eben framework-timeago = { $suffix -> [ago] vor - [in] in } { $value } { $unit -> diff --git a/src/locales/en-US/framework.ftl b/src/locales/en-US/framework.ftl index f03b2a50a..89eee969b 100644 --- a/src/locales/en-US/framework.ftl +++ b/src/locales/en-US/framework.ftl @@ -13,6 +13,8 @@ framework-validation-invalidEmail = Please enter a valid email address. framework-validation-passwordTooShort = Password must contain at least {$minLength} characters. framework-validation-passwordsDoNotMatch = Passwords do not match. Try again. +framework-timeago-just-now = Just now + framework-timeago-time = { $value } { $unit -> @@ -48,12 +50,7 @@ framework-timeago-time = } framework-timeago = - { $value -> - [0] now - *[other] - { $suffix -> - [ago] {framework-timeago-time} ago - [in] in {framework-timeago-time} - *[other] unknown suffix - } + { $suffix -> + [ago] {framework-timeago-time} ago + [noSuffix] {framework-timeago-time} } diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 29aac39c9..d939806a7 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -53,4 +53,16 @@ comments-replyCommentForm-submit = Submit comments-replyCommentForm-cancel = Cancel comments-replyCommentForm-rteLabel = Write a reply comments-replyCommentForm-rte = - .placeholder = { comments-postCommentForm-rteLabel } + .placeholder = { comments-replyCommentForm-rteLabel } + +comments-commentContainer-editButton = Edit + +comments-editCommentForm-saveChanges = Save Changes +comments-editCommentForm-cancel = Cancel +comments-editCommentForm-close = Close +comments-editCommentForm-rteLabel = Edit comment +comments-editCommentForm-rte = + .placeholder = { comments-editCommentForm-rteLabel } +comments-editCommentForm-editRemainingTime = Edit: remaining +comments-editCommentForm-editTimeExpired = Edit time has expired. You can no longer edit this comment. Why not post another one? +comments-editedMarker-edited = Edited diff --git a/src/locales/es/framework.ftl b/src/locales/es/framework.ftl index a383c75bb..86b5e6cb4 100644 --- a/src/locales/es/framework.ftl +++ b/src/locales/es/framework.ftl @@ -2,14 +2,12 @@ ### All keys must start with `framework` because this file is shared ### among different targets. +framework-timeago-just-now = Hace poco framework-timeago = { $value -> - [0] ahora *[other] { $suffix -> [ago] hace - [in] en - *[other] unknown suffix } { $value } { $unit -> diff --git a/src/types/react-timeago.d.ts b/src/types/react-timeago.d.ts index 431bbf88e..02161745c 100644 --- a/src/types/react-timeago.d.ts +++ b/src/types/react-timeago.d.ts @@ -36,6 +36,7 @@ declare module "react-timeago" { live?: boolean; className: string; formatter?: Formatter; + minPeriod?: number; } const TimeAgo: React.ComponentType;