diff --git a/client/coral-embed-stream/src/Comment.css b/client/coral-embed-stream/src/Comment.css index 24124f9ae..0429b18b3 100644 --- a/client/coral-embed-stream/src/Comment.css +++ b/client/coral-embed-stream/src/Comment.css @@ -13,6 +13,15 @@ pointer-events: none; } +.bylineSecondary { + color: #696969; + font-size: 12px; +} + +.editedMarker { + font-style: italic; +} + /* element in the top right of the Comment */ .topRight { float: right; diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js index cdbd9c978..ef40c2a64 100644 --- a/client/coral-embed-stream/src/Comment.js +++ b/client/coral-embed-stream/src/Comment.js @@ -39,6 +39,9 @@ class Comment extends React.Component { constructor(props) { super(props); + + // timeout to keep track of Comment edit window expiration + this.editWindowExpiryTimeout = null; this.onClickEdit = this.onClickEdit.bind(this); this.state = { @@ -92,7 +95,13 @@ class Comment extends React.Component { user: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired - }).isRequired + }).isRequired, + editing: PropTypes.shape({ + edited: PropTypes.bool, + + // ISO8601 + editableUntil: PropTypes.string, + }) }).isRequired, // given a comment, return whether it should be rendered as ignored @@ -116,6 +125,26 @@ class Comment extends React.Component { this.setState({isEditing: true}); } + componentDidMount() { + if (this.editWindowExpiryTimeout) { + this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout); + } + + // if still in the edit window, set a timeout to re-render once it expires + const msLeftToEdit = editWindowRemainingMs(this.props.comment); + if (msLeftToEdit > 0) { + this.editWindowExpiryTimeout = setTimeout(() => { + + // re-render + this.setState(this.state); + }, msLeftToEdit); + } + } + componentWillUnmount() { + if (this.editWindowExpiryTimeout) { + this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout); + } + } render () { const { comment, @@ -193,7 +222,14 @@ class Comment extends React.Component { { commentIsBest(comment) ? : null } - + + + { + (comment.editing && comment.editing.edited) + ?  (Edited) + : null + } + { (currentUser && @@ -201,9 +237,12 @@ class Comment extends React.Component { /* User can edit/delete their own comment for a short window after posting */ ? - Edit + { + commentIsStillEditable(comment) && + Edit + } /* TopRightMenu allows currentUser to ignore other users' comments */ @@ -346,3 +385,28 @@ class Comment extends React.Component { } export default Comment; + +// return a Date instance representing end of edit window for comment +const getEditableUntilDate = (comment) => { + const editing = comment && comment.editing; + const editableUntil = editing && editing.editableUntil && new Date(Date.parse(editing.editableUntil)); + return editableUntil; +}; + +// return whether the comment is editable +function commentIsStillEditable (comment) { + const editing = comment && comment.editing; + if ( ! editing) {return false;} + const editableUntil = getEditableUntilDate(comment); + const editWindowExpired = (editableUntil - new Date) < 0; + return ! editWindowExpired; +} + +// return number of milliseconds before edit window expires +function editWindowRemainingMs (comment) { + const editableUntil = getEditableUntilDate(comment); + if ( ! editableUntil) {return;} + const now = new Date(); + const editWindowRemainingMs = (editableUntil - now); + return editWindowRemainingMs; +} diff --git a/client/coral-embed-stream/src/EditableCommentContent.js b/client/coral-embed-stream/src/EditableCommentContent.js index f792985ec..6ac223844 100644 --- a/client/coral-embed-stream/src/EditableCommentContent.js +++ b/client/coral-embed-stream/src/EditableCommentContent.js @@ -5,6 +5,12 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from 'coral-framework/translations'; const lang = new I18n(translations); +const getEditableUntilDate = (comment) => { + const editing = comment && comment.editing; + const editableUntil = editing && editing.editableUntil && new Date(Date.parse(editing.editableUntil)); + return editableUntil; +}; + /** * Renders a Comment's body in such a way that the end-user can edit it and save changes */ @@ -23,7 +29,13 @@ export class EditableCommentContent extends React.Component { // comment that is being edited comment: PropTypes.shape({ - body: PropTypes.string + body: PropTypes.string, + editing: PropTypes.shape({ + edited: PropTypes.bool, + + // ISO8601 + editableUntil: PropTypes.string, + }) }).isRequired, // logged in user @@ -41,6 +53,22 @@ export class EditableCommentContent extends React.Component { constructor(props) { super(props); this.editComment = this.editComment.bind(this); + this.editWindowExpiryTimeout = null; + } + componentDidMount() { + const editableUntil = getEditableUntilDate(this.props.comment); + const now = new Date(); + const editWindowRemainingMs = editableUntil && (editableUntil - now); + if (editWindowRemainingMs > 0) { + this.editWindowExpiryTimeout = setTimeout(() => { + this.forceUpdate(); + }, editWindowRemainingMs); + } + } + componentWillUnmount() { + if (this.editWindowExpiryTimeout) { + this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout); + } } async editComment(edit) { const {editComment, addNotification, stopEditing} = this.props; @@ -74,6 +102,8 @@ export class EditableCommentContent extends React.Component { } render() { const originalBody = this.props.comment.body; + const editableUntil = getEditableUntilDate(this.props.comment); + const editWindowExpired = (editableUntil - new Date()) < 0; return (
+ { + editWindowExpired + ?

You can no longer edit this comment. The time window to do so has expired. Why not post another one?

+ :

You have to save this Edit. You may save this edit now to reset the clock.

+ }
); } } + +/** + * Countdown the number of seconds until a given Date + */ +class CountdownSeconds extends React.Component { + static propTypes = { + until: PropTypes.instanceOf(Date).isRequired + } + constructor(props) { + super(props); + this.countdownInterval = null; + } + componentDidMount() { + const {until} = this.props; + const now = new Date(); + if (until - now > 0) { + this.countdownInterval = setInterval(() => { + + // re-render + this.forceUpdate(); + }, 1000); + } + } + componentWillUnmount() { + if (this.countdownInterval) { + this.countdownInterval = clearInterval(this.countdownInterval); + } + } + render() { + const now = new Date(); + const {until} = this.props; + const msRemaining = until - now; + const secRemaining = msRemaining / 1000; + const wholeSecRemaining = Math.floor(secRemaining); + const plural = secRemaining !== 1; + return {`${wholeSecRemaining} second${plural ? 's' : ''}`}; + } +} diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 087859e89..76993725f 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -317,9 +317,7 @@ button.comment__action-button[disabled], } .coral-plugin-pubdate-text { - color: #696969; display: inline-block; - font-size: .75rem; margin-left: 5px; } diff --git a/client/coral-framework/graphql/fragments/commentView.graphql b/client/coral-framework/graphql/fragments/commentView.graphql index 0ed5e00b8..76f4c8caa 100644 --- a/client/coral-framework/graphql/fragments/commentView.graphql +++ b/client/coral-framework/graphql/fragments/commentView.graphql @@ -15,4 +15,8 @@ fragment commentView on Comment { action_summaries { ...actionSummaryView } + editing { + edited + editableUntil + } } diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index 19ea11efe..b52361f40 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -1,3 +1,5 @@ +const CommentsService = require('../../services/comments'); + const Comment = { parent({parent_id}, _, {loaders: {Comments}}) { if (parent_id == null) { @@ -45,6 +47,14 @@ const Comment = { }, asset({asset_id}, _, {loaders: {Assets}}) { return Assets.getByID.load(asset_id); + }, + editing(comment) { + const editableUntil = CommentsService.getEditableUntilDate(comment); + const edited = comment.body_history.length > 1; + return { + edited, + editableUntil, + }; } }; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index a88baa2e8..c28e38d00 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -166,6 +166,11 @@ input CommentCountQuery { tag: [String] } +type EditInfo { + edited: Boolean! + editableUntil: Date +} + # Comment is the base representation of user interaction in Talk. type Comment { @@ -207,6 +212,9 @@ type Comment { # The time when the comment was created created_at: Date! + + # describes how the comment can be edited + editing: EditInfo } ################################################################################ diff --git a/services/comments.js b/services/comments.js index 40f0ccce0..c87fc313a 100644 --- a/services/comments.js +++ b/services/comments.js @@ -61,13 +61,12 @@ module.exports = class CommentsService { // it's an id comment = await this.findById(comment); } - const lastEditDate = (comment) => { - const {created_at, body_history} = comment; - const lastEdit = body_history[body_history.length - 1]; - return lastEdit.created_at || created_at; + const editWindowExpired = (comment) => { + const now = new Date; + const editableUntil = this.getEditableUntilDate(comment); + return now > editableUntil; }; - const editWindowExpired = (new Date() - lastEditDate(comment)) > EDIT_WINDOW_MS; - if (( ! ignoreEditWindow) && editWindowExpired) { + if (( ! ignoreEditWindow) && editWindowExpired(comment)) { throw Object.assign(new Error('Edit window is over.'), { name: 'EditWindowExpired' }); @@ -97,7 +96,20 @@ module.exports = class CommentsService { case 0: throw new Error(`Couldn't edit comment. There is no Comment with id "${id}"`); } + } + /** + * Until when can the provided comment be edited? + * @param {Comment} comment - comment to check last edit date of + * @returns {Date} last date at which comment can be edited + */ + static getEditableUntilDate(comment) { + const mostRecentEditDate = (comment) => { + const {created_at, body_history} = comment; + const lastEdit = body_history[body_history.length - 1]; + return (lastEdit && lastEdit.created_at) || created_at; + }; + return new Date(Number(mostRecentEditDate(comment)) + EDIT_WINDOW_MS); } /** diff --git a/test/server/graph/mutations/editComment.js b/test/server/graph/mutations/editComment.js index cd213ac35..39256d17b 100644 --- a/test/server/graph/mutations/editComment.js +++ b/test/server/graph/mutations/editComment.js @@ -63,6 +63,7 @@ describe('graph.mutations.editComment', () => { console.error(response.errors); } expect(response.errors).to.be.empty; + expect(response.data.editComment.errors).to.be.null; // assert body has changed const commentAfterEdit = await CommentsService.findById(comment.id);