In this chapter, our goal is to create comment reply and thus finally finalize the comments. As you already know, in the previous chapter, we developed the reactions, this part right here. And today we're going to build this part right here. We're going to aim on reusing as many components as possible. And not only that, we will aim at not creating too many new procedures if not needed.
So basically we're going to focus on recursive type of development, starting by adding parent ID foreign key to our comment schema. After that we're going to create the UI for our replies, including this one reply here and the actual reply button, of course. And then we're going to have to create some variants for comment item. As you can see, both this right here and this are comment items. But this one has a visibly smaller avatar.
So that's what I mean by a different variant. We're just going to modify it a little bit. And also this indentation part here. And we're also going to have to do the same for the comment form, which you can't really see on this picture, but comment form will basically be this, but modified so that when we click reply here, it's just gonna be put in this area right here, and it's gonna be a little bit smaller as well. And instead of comment, you will see reply, and you will also have a cancel button.
And we have to create a new comment replies component. So this component will be used to render out the comments recursively with infinite load. Let's go ahead and do that. We're going to start by going inside of our schema and we're not going to have to do too many changes here. Let's find our comments.
And besides having user ID and video ID, we are now going to have parent ID. Let's try and add parent ID. So this will be parent ID and the reference for this will be, well, itself, comments.id. But there seems to be an issue here. When I hover over this it says that function implicitly has return type any because it doesn't have a return type annotation and is referenced directly or indirectly.
Well basically a confusing message. There is a solution for that, and this is a documented issue. So if you take a look at the foreign key documentation in Drizzle, you will see this exact example. If you want to do a self-reference due to a TypeScript limitation, you will have to either explicitly set the return type, so we would have to add the type here, right, by adding a type and then something inside of here, or we can do this, And this is the one that I prefer. So what I'm going to do instead is I'm just going to define this as a UUID.
And then inside of here, I'm going to get our table. And I'm going to go ahead and just immediately return an array, foreign key, like this. Let's make sure we import foreign key from pgCore. So we are only using PGCore in this schema. Make sure you don't have any imports from MySQL or any other database, right?
There we go. So now let's go ahead and use this new foreign key here. And let's give it columns of table, meaning t.parentId. That's the column. The foreign column it's targeting is itself id, and the name for this is going to be comments underscore parent ID foreign key.
One thing I forgot to add to this foreign key is on delete cascade as you can see in the screenshot attached. I will instruct you later before our deployment to fix this bug, but if you want to, you can fix it right now simply by adding the onDelete cascade. What this will allow you to do is properly cascade connected records through the parent ID. Without this, if you try to delete a comment which has many replies in it, you will get an error. So simply add this ahead of time.
There we go. Now that we have this, we can already push this. So let me go ahead and shut this down and just push my new changes. So this is an actual schema SQL change, right? Let's go ahead and do that.
Pulling schema from the database changes applied and if you go ahead and start the studio this is what you are going to see. Let's go inside of the comments here and you can see that we have the parent ID field and we also have this ID comments parent ID. So it detected a relation but it's not really named on an application level. So what we can do, this is of course optional as with every single relations that we've written so far because we exclusively use SQL Query Builders. What we can do now is the following.
The same way we added one video, we can add one parent to each comment. This will target themselves, the comments. It will use comments parent ID and the reference will be to comment ID. And we also need to be explicit with the relation name here. Comments underscore parent ID foreign key.
Like this. And besides reactions, we're also going to have replies, which is going to be an instance of our parent ID foreign key but in the form of many relation so this will be again comments but with relation name this one right here. There we go. So comments, parent, id, key. Now we just have to shut down our studio and start it again.
But if you want, you can, of course, try pushing this and you will see the same thing. No changes at all in a second. Let's wait. Pooling schema. No changes detected.
Exactly what we expected. And now we can restart our studio here. And what I'm hoping to see is proper parent and replies. So this is self referencing. Excellent.
That's it for our schema. Our next part is to create the UI elements for this. So let's go ahead and let's revisit our comment item component. Our comment item component will now get a new prop. So let's go ahead and go inside of here.
Besides comment, it's also going to have an optional variant. And this will either be reply or a comment. Let's go ahead and extract that here and default it to comment if nothing else has been passed like this. And now what we have to do is we have to go after our reactions. So let's find this div which is encapsulating our reactions here.
Let me go ahead and... So find span with comment dislike count and find where this div ends. And then If variant is comment, only then we are going to render a button with reply text inside. So only the main comment will have the reply button. Now let's give this a variant of ghost size of small and the class name height 8.
And on click for now is just going to be an empty arrow function. And there we go. We now have a reply button right here. Now let's go ahead and let's add some states here. I'm gonna go ahead and add, let me just find a place to do that.
How about I do it here? Is reply open? Set is reply open. Use state from React and default it to false Below that is Replies open, basically a list of replies and SetIsReplies open Make sure you've added UseState from react and let me just move that import here to the top. Now that we have this, let's go ahead and use it.
So when you click on reply, Set is reply open will be set to true. And now let's go ahead and render our comment form if this is opened. So I just found this, oh, I put this inside of the dropdown. I didn't even notice, but yes, that is correct. This is exactly what we want.
We wanna put it in the dropdown menu and also in our button here right so let's just do that here and one thing that we should also do here is check if variant is comment Like this. Make sure you only do that. There we go. So now, whoops. If this is true, what we are going to do is we're going to go and find the bottom of our drop-down menus here and find the last closing div in between the complete last one, do the following.
Last one, do the following. If isReplyOpen and if variant is comment, go ahead and create a div with a class name marginTop of 4 and pl of 14 and inside you're going to render the comment form once again. You will pass in the video ID to be comment video ID. You will pass in the variant, Oh, which doesn't yet exist. So let's add on success here.
That's the only thing we can do. On success we'll do set is reply open back to false and set is replies open to true in case they weren't. There we go. So now make sure you have imported the comment form from ./.commentform. Let me see what my error is about.
So is replies open? Okay, because I'm not using that. That's fine. So now when you click reply, You should be having a form appear right here. Excellent.
What we have to do now is we have to modify the comment form itself. Let's go inside of the comment form and let's add a variant here. It will be optional, either a comment or a reply. Besides video ID let's add an optional parent ID so we know who are we replying to and besides on success let's have an optional on cancel. Now let's add all of those.
Parent ID, on cancel and variant. There we go. Let's set the variant to be comment by default. And now let's do all the modifications needed for the variant. First of all, let's find our text area here and let's change the placeholder.
If variant is reply, in that case it will be replied to this comment. Otherwise, add a comment. Now let's go back to the comment item here and let's set the variant to reply and let's add the parent id to be our comment. Actually, the parent ID will be the comment.id, right? This is the main comment that we are now replying to.
And on cancel, setIsReplyOpen will be false. Just is reply open, no need to close the replies themselves. There we go. Let's go ahead now and see the difference. This says add a comment and this one says reply to this comment.
So we are already noticing a difference. Now, Let's go ahead and go and find, instead of comment form, the submit here and change this. If variant is equal to reply, change this to reply, otherwise to comment. Now this says reply and this says comment. And now go ahead and render on cancel and render a special button, which will say cancel.
Give this a variant of ghost, a type of button, very important, and on click will be handle cancel. Now we just have to quickly develop the handle cancel one. Basically what I want to do inside of handle cancel is just reset the form. So on handle cancel, first do form reset and then call on cancel. If it exists, even though it should exist at this point.
So make sure you do an optional chain here. Great, And let me just be consistent. Am I doing form reset on success? I am. Excellent.
Looks like we are only not using the parent ID at the moment. So now I should be able to click cancel. Excellent. So this seems to be working. And you can see how I have this avatar of mine because I'm signed out.
Let me go ahead and sign in just to confirm that this is working as expected here. Let me go back to my video here. If I click reply, there we go. Perfect. Now it's time for us to create an ability to actually create a reply.
And we can do that by using the parent ID. The only issue is we don't really have a place to use it. If I were to add this here, we could do it, right, like this, and this will be submitted. But the issue is if we go inside of our comments create protected procedure, so go here inside of your modulus comments server procedures, we are not really accepting that. Now let's add the parent ID here.
This will be string and UUID as well. The difference is that this will be optional, so add nullish here. So there should be no problem with passing this as the default value here. I'm pretty confident that this will simply be passed along with our values even though parent ID wasn't explicitly selected anywhere. We are going to debug if that doesn't happen.
So now besides video ID we can also destructure the parent ID here. And besides passing the video ID, we can also pass in the parent ID. And this is how we are going to create a reply. There is one limitation that I want to do first. I want to check that right now on the front end, we've created our variants, right?
So the reply is only possible on main comments, right? So later when we render a variant of comment item, which would be a reply, we will not allow the user to reply. So what I need is I need to create a logic that will further enforce that. This is something that I didn't do in my original source code, but now I'm noticing that I have to do that to stay consistent because I don't want to allow a reply on a reply, right? That's too deep.
I just wanna stay one level. There's no technical reason I'm limiting in here. I just feel like it's gonna be easier to manage at this level. Then later on, you can modify it to allow multiple replies. And YouTube themselves don't really allow replies in a sense that this will just go deeper and deeper and deeper and deeper they start doing tags to a user but I feel like that's a different mechanism they're doing so what I'm gonna be doing is I'm going to check if we have an existing comment.
So await database, select from comments, where equals comments ID to our parent ID. Let me import equals. Oh, okay. What I have to do is the following. In array, comments ID, check if we have the parent ID.
If we do, it's going to be in array, otherwise an empty array. And now, if there is no existing comment, but there is a parent ID which we have sent, we are going to throw new TRPC error here with a code not found because we have no idea what did they want to reply to. So basically if parent ID has been passed, this means that this is no longer a create method, this is now a reply method. So we are going to check that here. So for regular comments which are not passing the parent id, we don't really care if we load this comment or not, right?
And now one more thing I'm going to do is if there is an existing comment and if we do have parent ID, and if existing comment has a parent ID already, that means this existing comment is a reply. So I think that I can do that. If existing comment, parent ID, I think this might be shorter, and we have a new parent ID that we sent, this would mean that we are doing a reply on a reply. So that's what I want to prevent. And I'm going to pass in bad request here because that's not something we are going to allow.
Otherwise, no problem at all. Go ahead and do it. So let's go ahead and check that out now. So I have a pretty simple database here. Looks like actually I have seven comments.
Okay, what I'm going to do is I'm going to remove all comments. And this should also remove all comment reactions. Let's just refresh. There we go, no comment reactions because of the foreign keys. Let me refresh this again.
And now what I'm going to do is I'm going to call this main comment and I will leave a comment and I will add a reply here. This is a reply and I will click reply. And now what happens is that this reply is loaded with the main comments. That's fine because we don't know how to handle those things yet. All that our get many procedure knows is, hey this is one of the comments for the video.
That's all I know. I have no idea what parent ID means, but we know what it means. So let's just confirm in our database that the correct thing has happened. I should have two comments. The main comment should have no parent id.
The reply comment should have a parent id and it does. The main comment should have no parent, no rows, but it should have a reply and it does with the text this is a reply. The reverse will be true for our reply comment. It should have a parent relation. We can see it right here, main comment.
But it should not have any replies. There we go. That is correct. Now what we have to do is we have to modify our getMany procedure so that it correctly does the difference between what's a reply and what's a comment, right? And we can decide how we wanna count comments.
Do you wanna count a reply as a comment or not? You can decide that for yourself. I'm not exactly sure which one YouTube does, but we will go over how we can change that. So you are already in the comments procedures. So what you have to focus on now is the get many base procedure.
So what we are going to do is we are going to change our data right here to only load comments which don't have a parent ID. So we have to go inside of our where method. And besides getting the comments for this video ID and the cursor, let's also add is null from drizzle or ram comments, parent ID. That's it. We just care about parents ID being null so now if I refresh there we go you can see that this says main comment So depending on how you want to handle it, do you want to count this as two comments?
Because technically it will be two comments. I think in my original source code, I counted all comments, right? I don't think I modified this, but if you don't want to count this as two comments, what you can do is just add this to the query. It's as simple as that. So instead of having one, it's going to have two queries here.
The first one is a check for the video ID. And the other one is a check is now comments parent ID. And there we go. Now it says one comment. So if you don't like that, if you think this should be two comments, because there are technically two comments, just one is a reply, then remove that line and it should say two comments.
Now, what we have to do is we have to add a reply count so we know to display to the user, hey, this comment right here has some replies. So let's focus on this query right here and the same way we've added like counts let's add the reply count now. So we can do database count, We can add comments here and then we're going to do the query. And in this case, we're going to check if comments.parentId matches... Did I do this correctly?
Let me check. If comments ID matches comments parent ID. Or maybe this is not how I want to do it. I'm going to check. Let's see.
So the comments ID matches, let's see, what will this give us? Let's leave it like this. I think that I might be making a mistake here, but let's try and render the comment counts. I'm going to go ahead and go inside of the comment item. And after we render this conditional comment form, let's do this.
If comment.replyCount is larger than zero, And if variant of this is a comment, then display a div with a class name of PL14 and the button component, which will very simply do comment, reply count, replies. Right now, it renders nothing because it detected the reply count is zero, I suppose. So let me see. I am doing something incorrectly here. I think that I cannot count the self-referencing queries the way I wanted to count them.
So what I'm going to do is something else. I'm gonna go ahead and create a comment table expression for my replies. So the same way we created viewer reactions, we're now going to create our replies using database with replies as database select parent ID comments parent ID count will be count comments dot ID from comments where is not null. So we're doing the opposite now. We're only counting the comments who have the parent ID.
And we have the group by comments parent ID here. And we also have to do as count here. We are going to try to remove both this and the group by and hopefully get errors so you will see why we need them. So now simply join the replies alongside viewer reactions here and you also have to do a left join here. So let's add left join on our common table expression replies and query by this parent ID.
There we go. So now let's modify our reply count to very simply use our new joined table and access the count because we counted inside. Oops here we counted here. So now if I refresh there we go. I already saw it for a second but now it clearly says one replies.
So let me try removing this first. Now if I refresh I get an error and here's what happens. You tried to reference count field from a subquery aka our common table expression which is a raw SQL field but it doesn't have an alias declared. Please add an alias to the field using .asAlias method. And that's exactly what you can find in the documentation about common table expressions.
Let me find a width here, common table expressions. There we go. For example, insert. I'm trying to find exactly where we can find the documentation of what we just did, but this is basically the documentation here for commentable expressions. And you can see that whenever you do a raw SQL query, you have to define as to give it a name, right?
Because this is just a JavaScript object. This by itself doesn't know what field it's supposed to be. So that's why we have to add as count here and then here we can read that count. And the group by it exists in my source code but let's see do we need it or not. I'm going to refresh here.
Looks like we need group by. So the column parent ID must appear in the group by because by clause or be used in an aggregate function. So in this case we need group by. This is why I was so skeptic about having groupby in our videos procedures. Let me find our videos procedures here inside of our get one base procedure.
Right? Because I saw that we have groupby and I know that if a groupby is missing there will be an error, right? But it looks like it's not needed. It looks like whatever we are doing here is fine and I believe it's because of this way of using subqueries, right? We are not doing any count inside of our commentable expressions.
So because of that these are not aggregate queries and they don't need to be grouped. I believe that's the reason why. But for our comments, we are using a comment table expression where an aggregate is being made, thus we need to group by parents ID so the count can be actually correct. Otherwise, it will be counting incorrectly. We will get incorrect data.
Perfect. So that seems to be working. So now these two comments actually make sense when you see one reply here. Perfect. Now let's go ahead, go back inside of our comment item and let's go ahead and give this button some more attributes.
So starting from the size, which is going to be small, and then on click here, which will do set is replies open and it will simply modify it whatever is the current value like this and then inside we're going to check if is replies open do chevron up icon from lucid react otherwise do Chevron down icon from Lucid React. Make sure you have imported both of these icons. Chevron down icon and Chevron up icon. Let me just refresh this. There we go.
And you can see how I can open and close this. Now what I want to do is I want to create a new, a variant for the button. So go inside of your source components UI button, go inside of your variants here, and after link, you're going to add, I never know how to pronounce pronounce this variant tertiary I'm not a designer so I apologize in advance tertiary I guess BG will be background hover will be background blue 500 with a 10% opacity and a text blue of 500. And now, just go ahead and give this variant here. There we go.
And now it should look much, much better. Before we go on and build the actual replies UI here, I want to go back to my comments procedure and just wrap it up here. Because here's what we need to do. We are from now on going to accept another input in our getManyBase procedure. You might have guessed it.
That's going to be the parent ID. And it will be optional. Nullish. And if we do have a parent ID, we're going to query a little bit differently. So this will be a query, which we're going to reuse for replies as well.
This is the goal, right? To recreate a recursive kind of query. So this is not the parent ID which we are going to be using. These are schema references. We are going to focus on this, our query right here, more specifically, our where here.
I really don't like how this end is here. I want to collapse it like this. I want to indent this and I want to indent everything inside of the end. There we go. So now I've added this.
Only load comments which don't have a parent id. We are now going to change that to be dynamic. So if we have parent ID passed in our input, then obviously we are expecting replies for this comment to be loaded. So let's add equals comments, parent ID, parent ID, as simple as that. Otherwise, we are expecting this.
And what we have to do is we have to extract the parent ID from the input right here. And I believe that's all we are going to need to load the replies. So, instead of here, when we load our where, we're going to check if parent ID has been passed. Obviously, the user wants to load the replies for this parent otherwise. We're simply going to load all comments which don't have a parent ID.
The main comments. Excellent! I believe that that is it and now what we have to do is the following. Let's go ahead and let's go inside of our comment item component here And now we have to create a new component called comment replies But we are only going to render that component if comment reply count is larger than zero and if the variant is comment and isReplies open only then are we going to render commentReplies component and this component will not be too difficult to create, don't worry. We will simply pass the parent id to be the parent id.
We will pass, my apologies, comment id. We will pass the video id to be video comment video id. That's it. Now let's go ahead and let's create the comment replies inside of here. So comment-replies.tsx.
Let's create an interface comment-replies props and export const comment-replies like this. Inside of here we are going to assign comment replies props with parent ID and video ID here and let's return a div and let's just say replies. There we go. Now let's go ahead and let's import the comment replies from .slash comment replies. Let me see what my error is here.
This should be video ID, correct. So now when I click here, there we go. It says replies. When I close, it's hidden. What we have to do here is we have to fetch our replies.
But so far, we've only been fetching in one way, prefetch and then suspense. But remember, we don't have to do that. The reason we are doing that is to improve our load times. We are doing that because we are working with server components, but This is a perfect example of something a server component is not. A conditional, optional load of reply.
Let's go ahead and simply call an infinite query here. So we are going to extract the data from trpc, from the client, and we will call the same method that we are calling in our comments section getMany and instead of using useSuspense infinite query because we are not prefetching there's nothing to suspense there's no streaming being done We will simply use use infinite query for the first time from the client and there's no problem with doing that at all. Let's pass in the limit to be our default. Where is my limit? There we go.
Default limit from my constants. Let's pass in the video ID. Let's pass in the parent ID because we have it. And let's go ahead and add the get next page param to be last page, last page, next cursor. There we go.
And inside of here let's do JSON stringify data. And now there we go. You will see the reply here. Let me just try and find the text for my comment here. Can I find the reply?
There we go. Value. This is a reply right here. Exactly. That is our second item in the database here.
This is the reply. So we loaded the correct thing here. And now we just have to style it properly. Let's start by giving this a class name of PL14. So it's pushed here, right?
Then let's go ahead and create a little bit of loading states because this will have a normal client-side loading state, no suspense here and that's perfectly fine because this is a conditional load, right? Flex column, get for margin top of two. And now let's extract isLoading from here and let's just do if is loading go ahead and render a div with Loader2 icon from Lucid React and give this a class name of Size6, animate spin and text muted foreground and give its wrapping div a flex item center and justify center. And I completely butchered this. Let's go ahead and ensure that you have this imports.
So now if you do a hard refresh here and click this it should load for a second before you see anything. And now below that what we are going to do is we're going to check if we are not loading and if we have data, question mark, pages, do a flat map over each page and return immediately all the items from the page and then over all of those items we are simply going to get the comments and guess what we will just reuse our comment item component pass in comment ID comments to be comment and finally a variant of reply. Make sure you have imported the comment item component. Perfect. Let's go ahead and try it out.
And there we go. There's one thing we forgot to do for the reply variant of the comment item, and that is to change the avatar size. So modify this. If variant is comment, Simply do large, otherwise do small. There we go.
So now it's going to display much smaller, so there's more space, right? This is similar to how YouTube does it. There's one more thing to do here. Instead of our comment replies, we also have to add our infinite loading here. Now we could add our infinite scroll component, but I don't like the UI of it.
And honestly, it's not too hard for us to create a new infinite scroll here, but it's not actually going to be an infinite scroll because that would be insane. Imagine opening something with a million replies and then you would just infinitely be loading all of those replies. You would never be able to scroll down to the next comment. So no, we are not going to do any automatic scrolling or loading here. This will be manual fetching.
So that's even easier for us to do. From here, let's go ahead and extract everything we need. So that's gonna be the data that is loading, which we already have. And then we're gonna have fetch next page, has next page, is fetching next page, these three items here. And that's enough for us to build everything we need.
So, after this div here, check if hasNextPage. If we do, give the user a button from components UI button and simply say show more replies like this. Right now this will not happen because by default we will load five items here. So I need to make five replies first. Another reply.
Then another reply too. I completely butchered this. Something else. And just a few more. Ho ho.
And he he. Now we have six replies and this should be enough to detect that we need to show more replies. So let's go ahead and refresh. And now when you click here, it should load 1, 2, 3, 4, 5 and give you a button to show more replies. So what we're going to do is the following.
Give this a variant of tertiary, a size of small, onClick of Fetch Next Page, and disabled is fetching next page. And also give this a corner down right icon from Lucid React. And now when you click here, there we go. It should simply load more items. Excellent.
You should be able to reply now and those comments should appear here. Amazing. That's it. All other functionality is already working. There's nothing else we have to build here.
There is one edge case, I believe. So let's first just try deleting something. So I'm deleting he, he reply. Let me delete that. There we go.
Works perfectly. But there is one, I think, weird thing that might happen. That is, if we are not the owner of this comment, we will still see this drop-down menu, but nothing will be inside because we're not going to allow the user to reply as here. Yeah, also this reply should also open this now. But we should also not be able to even give them this.
So let's go ahead and create that option here. Basically, this is the sign out option, I believe. If I go back now and just refresh this so I don't have the logged in state, there we go. There's nothing to show to this user. So how about we go inside of comment item here, find the dropdown menu itself, and just completely disable it if that happens.
So that's the, if we are neither the owner, so this, if we are not the owner. And if the variant is a reply, there's nothing we can do from this dropdown menu. Like this, There we go. So now, yeah, and in that case, this will always be a variant comment. Yes, this is correct.
We then don't need this for the drop-down menu. We will always be able to reply because this has to be the main comment then. Correct, that is a good observation by our linter here. So let's go ahead and check that out now. Let me try and refresh this.
Oh, it looks like I did something incorrectly here. If the variant is comment, that's what we're looking for, not reply. There we go. So now we still have an option to reply here because that's something we can do, even though this will throw an error for this user, it doesn't matter. But for this, there is literally nothing we can do except if we are logged in and if we are the author of that comment, right?
So only then, oh, it looks like Now I have an issue for this comment. That's interesting. Let's see. Variant is comment. Maybe that's not what I wanted.
All right, here's what I'm gonna do. I'm gonna remove this for now. My brain is completely fried from implementing this entire thing that I don't even think I can think clearly anymore. So instead, I'm just going to leave it like this for now. And perhaps in the next chapter, I'll get an idea of what I can do here.
You might see a complete logical solution to this, but at this point, honestly, I can't. I might be too tired. So feel free to try and do it yourself. I'm gonna see what I can do in the next chapter, but I believe that's it. There's one thing that we can do instead of the comment form, and that is here.
When we invalidate here I'm not sure you know how much this is needed because technically our replies right inside of our comment replies Our reply query also has the video ID, but it also has the parent ID. So what I did in my source code is I separately refreshed all comments and then all replies, right? Because we now have the parent ID here. So that's what I did. All right.
I'm not sure if we necessarily need to do this. But yeah, looks like it works well. Excellent. Amazing, amazing job. Everything should work just fine here.
And now we can finally go ahead and go into suggestions and we will be finished with the video ID page. Amazing, Amazing job. Let's go ahead and mark these things. This is finished. This as well.
Same here, here, here, and here. Amazing, amazing job.