WordPress comments package

I think we should at least inform the users that their comments are awaiting moderation:

These are the latest changes to this:

Thanks for the video, pretty useful :slightly_smiling_face:

To understand this better: In the first version of the package we were planning to just populate the state with the comments of the posts and include an action to post new comments. I feel that, for this first version, we should take care of informing the theme developer about this somehow and the theme developer should decide if they want to show the message in the form or wherever they want right?

Yeah, thanks Luis!

I didn’t realize we have that information in the URL to which WordPress redirects you after submitting a comment. We have also the comment ID, and that would be very useful to know if the comment was approved later.

We can get that URL from the Location header of the HTTP response and parse it. It would have this two possible formats:

  • Unapproved
    It would have two query parameters: unapproved and moderation-hash. Also, it would have a hash fragment with the comment ID.

    `http://frontity.site/post/?unapproved=${commentId}&moderation-hash=${hash}#comment-${commentId}`
    
  • Approved
    It would contain only the hash with the comment ID.

    `http://frontity.site/post/#comment-${commentId}`
    

For this, I would add a new property in the form status called isApproved.

But I’m not sure where to store the comment ID, and I was wondering if it wouldn’t make sense to store a list of submitted comments for each form, with the status of each submit, the field values, the comment ID and the date it was sent.

Well, I’m not sure because WordPress only shows the last pending comment that was sent, but at least we would have to separate state.comments.forms[postId].fields from the submitted comment.

I’m gonna give it a thought. :thinking:

Good idea :slightly_smiling_face:

Awesome!

I decided to use the following interfaces:

interface Form {
  fields: Fields; // Fields used by this form.
  submitted?: Submitted; // This value doesn't exist if nothing was submitted yet.
}

interface Fields {
  comment: string;
  author: string;
  email: string;
  url?: string;
  parent?: number;
}

interface Submitted extends Fields {
  isPending: boolean; // The comment hasn't been received by WP yet.
  isUnapproved: boolean; // The comment has been received but not accepted yet.
  isApproved: boolean; // The comment has been received and is published.
  isError: boolean; // The request has failed.
  errorMessage: string; // Failure reason.
  date: Date; // Submission date.
  id?: number; // Comment ID if it has been received (`isUnmoderated` or `isPublished`).
}

interface Comments extends Package {
  state: {
    comments: {
      forms: Record<string, Form>;
    };
  };
  actions: {
    comments: {
      submit:
        | AsyncAction<Comments, number>
        | AsyncAction<Comments, number, Fields>;
      updateFields: Action<Comments, number, Partial<Fields>>;
    };
  };
}

The idea is to use the submitted object this way or similar (at the end or the beginning of the comments list):

const Submitted = ({ state, actions, postId }) => {
  // Get the corresponding form using the post ID passed as argument.
  const { submitted }= state.comments.forms[postId];

  // Return `null` if nothing was submitted yet.
  if (!submitted) return null;

  // Show a pending message until we get a response from WordPress.
  if (submitted.isPending) {
    return <div>Sending comment...</div>
  }

  // Show an error message if something bad happend.
  if (submitted.isError) {
    return <div>
      There was an error while sending the comment: {submitted.errorMessage}
    </div>
  }

  // Show the submitted comment if is unmoderated or is not in the state yet.
  if (
    submitted.isUnapproved ||
    submitted.isApproved && !state.source.comment[submitted.id]
  ) return (
    <>
      <div>
        {submitted.isUnmoderated && <div>Comment awaiting moderation</div>}
        <CommentDate date={submitted.date} />
        <CommentAuthor>{submitted.author}</CommentAuthor>
        <CommentText>{submitted.comment}</CommentText>
        <CommentEmail>email: {submitted.email}</CommentEmail>
        <CommentURL>url: {submitted.url}</CommentURL>
        {submitted.parent && <CommentParent id={submitted.parent} />}
      </div>
    </>
  );
}

I’m not 100% sure about this API, but I guess the best way to test it is actually implementing it, so let’s start with it and see how it goes.

1 Like

Final implementation

This was implemented in two different PRs (#495 and #521) and included these things:

Comments handler

This is a wp-source handler for fetching comments from a specific post using its ID. For example, to fetch all comments that belong to the post with ID 60 you would do:

await actions.source.fetch("@comments/60");

This would fetch all comments published in that post and populate a data object inside state.source.data with a tree structure of comments and replies, sorted by date (most recent first).

To access the fetched comments you could use something similar to this example:

const Comments = connect(({ postId, state }) => {
  // Get comments from state.
  const data = state.source.get(`@comments/${postId}`);

  // Utility to render comments and replies recursively.
  const renderComments = (items) => items.map(({ id, children }) => (
    <Comment id={id}>
      {/* Render replies */}
      {children && renderComments(children)}
    </Comment>
  ))

  // Render comments if data is ready.
  return data.isReady
    ? renderComments(data.items)
    : null;
});

forms object

The wp-comments package stores in state.comments.forms a map of objects by post ID, representing each one a comment form. These objects are intended to be used as the state of React <form> components and contain the input values as well as the submission status. They have the following two properties:

  • fields: the following map of fields, representing the current field values that have been input in the form rendered in the given post. The content of this property is updated using the updateFields() action described later.

    Name Type Description
    author string Author’s name
    email string Author’s email
    comment string Text of the comment
    url string URL of the author’s site
    parent number ID of the comment to which this one replies
  • submitted: object that store field values when the form was submitted along with the submission status. It has the same properties described in fields (with the values that were sent to WordPress) and also the next ones:

    Name Type Description
    isPending boolean The comment hasn’t been received by WP yet
    isOnHold boolean The comment has been received but not accepted yet
    isApproved boolean The comment has been received and is published
    isError boolean The request has failed
    errorMessage string Failure reason
    timestamp number Submission timestamp (in milliseconds)
    id number Comment ID if it has been received (isOnHold or isApproved)

updateFields() action

Update the fields of the form specified by postId. This action simply updates what is stored in state.comments.form[postId].fields with the given values.

If no fields are specified, the form fields are emptied.

actions.comments.updateFields(60, {
  comment: "Hello world!"
});

submit() action

This asynchronous action publishes a new comment to the post specified by postId. It submits the fields stored in the respective form (i.e. state.comments.form[postId]) or the fields passed as a second argument. If fields are passed, those replace the current values stored in state.comments.form[postId].fields.

After calling this action, you can access state.comments.forms[postId].submitted properties (described above) to know the submission status.

// Submit the comment to the post with ID 60
// using the values stored in `state.comments.forms[60].fields`.
await actions.comments.submit(60);

// Submit the comment to the post with ID 60
// using the values passed in the second argument.
await actions.comments.submit(60, {
  comment: "This is a comment example. Hi!",
  author: "Frontibotito",
  email: "frontibotito@frontity.com",
});
3 Likes

@david I’ve been thinking that maybe it should support more than one awaiting comment, don’t you think?

Right now, if I submit one comment it’ll end up in submitted. But if I submit a second one, it will replace the previous one.

So maybe we should separate the concept of “form” from the concept of “submission” to allow more than one submission. Something like this (very rough):

interface Form {
  fields: Fields; // Fields used by this form.
  isPending: boolean; // The comment hasn't been received by WP yet.
  isError: boolean; // The request has failed.
  errorMessage: string; // Failure reason.
}

interface Submission {
  isUnapproved: boolean; // The comment has been received but not accepted yet.
  isApproved: boolean; // The comment has been received and is published.
  date: Date; // Submission date.
  id?: number; // Comment ID if it has been received (`isUnmoderated` or `isPublished`).
}

interface Comments extends Package {
  state: {
    comments: {
      forms: Record<string, Form>;
      submissions: Record<string, Submission[]>;
    };
  };
}

Some structure so it’s simple to show the awaiting comments, even if there are more than one. In the future, we can save those in LocalStorage so if the user refreshes the page it can still see his/her awaiting comments.

What do you think?

I think it would be a good idea, although Submission would have to include the fields submitted because in the case comments are not approved then you could get those comments neither from the state nor from the REST API even though you have the comment ID.


Apart from that, I’m currently facing a problem with the actual implementation that I don’t know how to solve and it’s driving me nuts. It turns out that I did a big mistake in the design :sweat:, specifically in the part that handles the WordPress response when a comment is submitted.

My idea was to do a fetch() using the redirect: "manual" option, assuming that the browser won’t redirect responses with status 302. Well, that’s the behavior indeed, but I was expecting JavaScript to receive a Response object with status 302 and that’s not the case. What it actually receives is a Response of type opaqueredirect which is basically an empty Response object from which we cannot extract any information, and we need the Location header in order to get if the comment is approved or not and the comment ID (by the way, it seems like I wasn’t the only one confused by redirect: "manual": https://github.com/whatwg/fetch/issues/763).


Excalidraw sketch

Tests are not failing because fetch() is mocked and Response objects with status 302 are created programmatically. But, in the browser, you won’t ever receive a 302 response using fetch().

So, knowing this, I tried just letting redirections happen. But then I discovered another weird thing. Apart from the fact that you will fetch the HTML code from the final URL, that URL is not stored in the Location header as before but inside Response.url. The problem is that url attribute is missing the fragment part (i.e. #comment-12) so we can’t know the comment ID in this case. :frowning_face:


Excalidraw sketch

In case the comment is unapproved we can know the ID because it appears in the query and the query does remain in Response.url, but if it is approved then there is no way to know the comment ID.

I guess I would need some help here, @dev-team!

Why do you need the comment ID? To scroll down?

It would be useful to know if the comment is populated in state.source.comment after fetching again the comments using force: true, but I guess we can live without that.

Oh, I forgot to mention that when you let the browser do redirections you will have CORS errors unless you explicitly set the appropriate headers in WordPress. In the other case it is not needed.

That’s a bummer. I would try to avoid CORS problems if we can.

Is there a way to differentiate between approved and unapproved without the redirection?

No, as far as I know.

You can know if the comment was accepted but the only way to differentiate between approved or unapproved is to check the content of the URL to which you are being redirected.

And you won’t have that URL unless you follow the redirection.

One thing that comes to my mind is to update the comments after a submission with

await actions.source.fetch(..., { force: true });

and look for the comment that was published right before in state.source.comment. If it appears there we can assure it is approved. Otherwise, we could assume that it is unapproved.

I see.

If we use the redirection approach, what do we gain?

I’ll write here a little summary of the pros and cons of each approach this weekend.

Regarding CORS, I realized it could give problems for both approaches in case the response status is different than 3XX. If it returns 200 or 409 (the other two cases covered) I guess the browser will complain.

EDIT: confirmed, we cannot avoid CORS problems in those cases unless we add Access-Control headers in WordPress. I guess the only way to overcome this problem would be a server extension, but that’s not an option at this moment. :pensive:

Interesting. Well, if we cannot avoid CORS then maybe the upcoming Frontity WordPress plugin needs to be a requirement for this package. It could add CORS for the decoupled domain.

Is there any other option we can consider? The REST API is not an option right now, is it?

I’ve run some tests to understand this better and it seems the correct approach using the REST API would be to make a POST request to /wp/v2/comments as explained in the handbook. It seems the problem was that you cannot post a comment if you’re not logged in, even if the option "Users must be registered and logged in to comment " is disabled in WordPress.

I’ve seen that adding the following code allows posting new comments from the REST API if you’re not logged in:

add_filter( 'rest_allow_anonymous_comments', '__return_true' );

Once I added that line, I was able to POST new comments using that endpoint. Just passing the params we were using in the current implementation, WordPress returns a response JSON with all the info we need: the id, the url, if it’s approved or on hold…

Would this be an option to consider? Would it make sense to make a Pull Request to WordPress REST API to add this code if that option is disabled?

Btw, this approach would solve the CORS problems as it would be a common REST API request. This is the documentation of the filter used -> https://developer.wordpress.org/reference/hooks/rest_allow_anonymous_comments/

1 Like

Oh, thanks @SantosGuillamot!

I think this is a great idea and simple to implement. Also, a POST request to /wp/v2/comments/ returns the comment added to WordPress in the same format a GET requests does so we would have all the information needed to know if the comment was accepted or not, just reading the status property.

And it is just a line of code in WordPress to make it work. :raised_hands:

If we follow this idea, we would have to change the request and response logic but it wouldn’t be a difficult task.

One more thing, thinking about the @luisherranz’s idea of adding a submissions array for each post, in this case that array could be simply the array of comments received for each POST request to /wp/v2/comments, as they would already have all the needed properties (id, date and status).