Skip to content

Commit

Permalink
Added in-reply-to support to comments API
Browse files Browse the repository at this point in the history
ref https://linear.app/tryghost/issue/PLG-230

- adds `in_reply_to_id` to API output
- adds `in_reply_to_snippet` to API output
  - dynamically generated from the HTML of the replied-to comment
  - excluded if the replied-to comment has been deleted or hidden
- adds `commentSnippet` to `@tryghost/html-to-plaintext`
  - skips anchor tag URLs as they won't be useful for snippet purposes
  - skips blockquotes so the snippet is more likely to contain the unique content of the replied-to comment when it's quoting a previous comment
  - returns a single line (no newline chars)
- allows setting `in_reply_to_id` when creating comments
  - id must reference a reply with the same parent
  - id must reference a published comment
- adds email notification for the original reply author when their comment is replied to
  • Loading branch information
kevinansfield committed Nov 7, 2024
1 parent f9b0280 commit 79f41dc
Show file tree
Hide file tree
Showing 12 changed files with 677 additions and 2,184 deletions.
1 change: 0 additions & 1 deletion ghost/core/core/server/api/endpoints/comments-members.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ const controller = {
},
options: [
'include'

],
validation: {
options: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
const _ = require('lodash');
const utils = require('../../..');
const url = require('../utils/url');
const htmlToPlaintext = require('@tryghost/html-to-plaintext');

const commentFields = [
'id',
'in_reply_to_id',
'in_reply_to_snippet',
'status',
'html',
'created_at',
Expand Down Expand Up @@ -42,6 +45,10 @@ const countFields = [
const commentMapper = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;

if (jsonModel.inReplyTo && jsonModel.inReplyTo.status === 'published') {
jsonModel.in_reply_to_snippet = htmlToPlaintext.commentSnippet(jsonModel.inReplyTo.html);
}

const response = _.pick(jsonModel, commentFields);

if (jsonModel.member) {
Expand All @@ -59,7 +66,7 @@ const commentMapper = (model, frame) => {
}

if (jsonModel.post) {
// We could use the post mapper here, but we need less field + don't need al the async behavior support
// We could use the post mapper here, but we need less field + don't need all the async behavior support
url.forPost(jsonModel.post.id, jsonModel.post, frame);
response.post = _.pick(jsonModel.post, postFields);
}
Expand All @@ -77,7 +84,7 @@ const commentMapper = (model, frame) => {
response.html = null;
}
}

return response;
};

Expand Down
17 changes: 11 additions & 6 deletions ghost/core/core/server/models/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ const Comment = ghostBookshelf.Model.extend({
return this.belongsTo('Comment', 'parent_id');
},

inReplyTo() {
return this.belongsTo('Comment', 'in_reply_to_id');
},

likes() {
return this.hasMany('CommentLike', 'comment_id');
},
Expand Down Expand Up @@ -181,25 +185,26 @@ const Comment = ghostBookshelf.Model.extend({
/**
* We have to ensure consistency. If you listen on model events (e.g. `member.added`), you can expect that you always
* receive all fields including relations. Otherwise you can't rely on a consistent flow. And we want to avoid
* that event listeners have to re-fetch a resource. This function is used in the context of inserting
* and updating resources. We won't return the relations by default for now.
* that event listeners have to re-fetch a resource.
*/
defaultRelations: function defaultRelations(methodName, options) {
// @todo: the default relations are not working for 'add' when we add it below
// @TODO: the default relations are not working for 'add' when we add it below
// this is because bookshelf does not automatically call `fetch` after adding so
// our bookshelf eager-load plugin doesn't use the `withRelated` options
if (['findAll', 'findPage', 'edit', 'findOne', 'destroy'].indexOf(methodName) !== -1) {
if (!options.withRelated || options.withRelated.length === 0) {
if (options.parentId) {
// Do not include replies for replies
options.withRelated = [
// Relations
'member', 'count.likes', 'count.liked'
'inReplyTo', 'member', 'count.likes', 'count.liked'
];
} else {
options.withRelated = [
// Relations
'member', 'count.replies', 'count.likes', 'count.liked',
'member', 'inReplyTo', 'count.replies', 'count.likes', 'count.liked',
// Replies (limited to 3)
'replies', 'replies.member' , 'replies.count.likes', 'replies.count.liked'
'replies', 'replies.member', 'replies.inReplyTo', 'replies.count.likes', 'replies.count.liked'
];
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ module.exports = class CommentsController {
if (data.parent_id) {
result = await this.service.replyToComment(
data.parent_id,
data.in_reply_to_id,
frame.options.context.member.id,
data.html,
frame.options
Expand Down
26 changes: 24 additions & 2 deletions ghost/core/core/server/services/comments/CommentsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ class CommentsService {
await this.emails.notifyPostAuthors(comment);

if (comment.get('parent_id')) {
await this.emails.notifyParentCommentAuthor(comment);
await this.emails.notifyParentCommentAuthor(comment, {type: 'parent'});
}
if (comment.get('in_reply_to_id')) {
await this.emails.notifyParentCommentAuthor(comment, {type: 'in_reply_to'});
}
}

Expand Down Expand Up @@ -253,11 +256,12 @@ class CommentsService {

/**
* @param {string} parent - The ID of the Comment to reply to
* @param {string} inReplyTo - The ID of the Reply to reply to
* @param {string} member - The ID of the Member to comment as
* @param {string} comment - The HTML content of the Comment
* @param {any} options
*/
async replyToComment(parent, member, comment, options) {
async replyToComment(parent, inReplyTo, member, comment, options) {
this.checkEnabled();
const memberModel = await this.models.Member.findOne({
id: member
Expand All @@ -281,6 +285,7 @@ class CommentsService {
message: tpl(messages.replyToReply)
});
}

const postModel = await this.models.Post.findOne({
id: parentComment.get('post_id')
}, {
Expand All @@ -291,10 +296,27 @@ class CommentsService {

this.checkPostAccess(postModel, memberModel);

let inReplyToComment;
if (parent && inReplyTo) {
inReplyToComment = await this.getCommentByID(inReplyTo, options);

// we only allow references to published comments to avoid leaking
// hidden data via the snippet included in API responses
if (inReplyToComment && inReplyToComment.get('status') !== 'published') {
inReplyToComment = null;
}

// we don't allow in_reply_to references across different parents
if (inReplyToComment && inReplyToComment.get('parent_id') !== parent) {
inReplyToComment = null;
}
}

const model = await this.models.Comment.add({
post_id: parentComment.get('post_id'),
member_id: member,
parent_id: parentComment.id,
in_reply_to_id: inReplyToComment && inReplyToComment.get('id'),
html: comment,
status: 'published'
}, options);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,13 @@ class CommentsServiceEmails {
}
}

async notifyParentCommentAuthor(reply) {
const parent = await this.models.Comment.findOne({id: reply.get('parent_id')});
async notifyParentCommentAuthor(reply, {type = 'parent'} = {}) {
let parent;
if (type === 'in_reply_to') {
parent = await this.models.Comment.findOne({id: reply.get('in_reply_to_id')});
} else {
parent = await this.models.Comment.findOne({id: reply.get('parent_id')});
}
const parentMember = parent.related('member');

if (parent?.get('status') !== 'published' || !parentMember.get('enable_comment_notifications')) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22699,7 +22699,7 @@ exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "17559",
"content-length": "17625",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
Expand Down Expand Up @@ -22867,7 +22867,7 @@ exports[`Activity Feed API Filter splitting Can use NQL OR for type only 2: [hea
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "5114",
"content-length": "5180",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
Expand Down Expand Up @@ -23914,7 +23914,7 @@ exports[`Activity Feed API Returns comments in activity feed 2: [headers] 1`] =
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1238",
"content-length": "1304",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
Expand Down
Loading

0 comments on commit 79f41dc

Please sign in to comment.