Overview
The Comments API enables time-based video annotations with support for threaded replies. Comments are associated with specific timestamps in videos and can be marked as resolved.
All comment functions are defined in convex/comments.ts.
Functions
create
Creates a new comment on a video at a specific timestamp.
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
function CommentForm({ videoId, currentTime }) {
const createComment = useMutation(api.comments.create);
const [text, setText] = useState('');
const handleSubmit = async () => {
const commentId = await createComment({
videoId,
text,
timestampSeconds: currentTime,
});
setText('');
};
return (
<form onSubmit={handleSubmit}>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">Comment at {currentTime}s</button>
</form>
);
}
Video timestamp in seconds where the comment is made
Parent comment ID for replies (optional)
The ID of the created comment
Permissions: Requires viewer role in the video’s team.
Implementation: convex/comments.ts:78
list
Lists all comments for a video, sorted by timestamp.
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
function CommentsList({ videoId }) {
const comments = useQuery(api.comments.list, { videoId });
return (
<div>
{comments?.map(comment => (
<div key={comment._id}>
<strong>{comment.userName}</strong> at {comment.timestampSeconds}s
<p>{comment.text}</p>
{comment.resolved && <span>✓ Resolved</span>}
</div>
))}
</div>
);
}
The video to list comments from
Array of comment objects sorted by timestampClerk user ID of comment author
Display name of comment author
Avatar URL of comment author
Video timestamp in seconds
parentId
Id<'comments'> | undefined
Parent comment ID (for replies)
Whether the comment is resolved
Timestamp when comment was created
Permissions: Requires access to the video.
Implementation: convex/comments.ts:64
getThreaded
Retrieves comments organized into threads (top-level comments with nested replies).
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
function ThreadedComments({ videoId }) {
const threads = useQuery(api.comments.getThreaded, { videoId });
return (
<div>
{threads?.map(thread => (
<div key={thread._id}>
<div className="main-comment">
<strong>{thread.userName}</strong> at {thread.timestampSeconds}s
<p>{thread.text}</p>
</div>
{thread.replies.map(reply => (
<div key={reply._id} className="reply">
<strong>{reply.userName}</strong>
<p>{reply.text}</p>
</div>
))}
</div>
))}
</div>
);
}
The video to get threaded comments from
Array of top-level comments with nested repliesArray of reply comments sorted by creation time
Permissions: Requires access to the video.
Implementation: convex/comments.ts:239
update
Updates the text of an existing comment.
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
function EditCommentForm({ commentId, initialText }) {
const updateComment = useMutation(api.comments.update);
const [text, setText] = useState(initialText);
const handleSubmit = async () => {
await updateComment({ commentId, text });
};
return (
<form onSubmit={handleSubmit}>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">Save</button>
</form>
);
}
Permissions: User can only edit their own comments.
Implementation: convex/comments.ts:183
remove
Deletes a comment and all its replies.
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
function DeleteCommentButton({ commentId }) {
const removeComment = useMutation(api.comments.remove);
const handleDelete = async () => {
if (confirm('Delete this comment and all replies?')) {
await removeComment({ commentId });
}
};
return <button onClick={handleDelete}>Delete</button>;
}
Permissions: Users can delete their own comments, or users with admin role can delete any comment on videos they have access to.
Implementation: convex/comments.ts:202
toggleResolved
Toggles the resolved status of a comment.
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
function ResolveButton({ commentId, isResolved }) {
const toggleResolved = useMutation(api.comments.toggleResolved);
return (
<button onClick={() => toggleResolved({ commentId })}>
{isResolved ? '✓ Resolved' : 'Mark as Resolved'}
</button>
);
}
The comment to toggle resolved status
Permissions: Requires member role in the video’s team.
Implementation: convex/comments.ts:227
Public Sharing Functions
These functions enable commenting on publicly shared videos.
createForPublic
Creates a comment on a public video using its public ID.
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
function PublicCommentForm({ publicId, currentTime }) {
const createComment = useMutation(api.comments.createForPublic);
const [text, setText] = useState('');
const handleSubmit = async () => {
await createComment({
publicId,
text,
timestampSeconds: currentTime,
});
setText('');
};
return <form onSubmit={handleSubmit}>...</form>;
}
The public ID of the video
Video timestamp in seconds
Parent comment ID for replies (optional)
Permissions: Requires authentication. Video must be public.
Implementation: convex/comments.ts:108
getThreadedForPublic
Retrieves threaded comments for a public video.
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
function PublicVideoComments({ publicId }) {
const threads = useQuery(api.comments.getThreadedForPublic, { publicId });
return (
<div>
{threads?.map(thread => <CommentThread key={thread._id} thread={thread} />)}
</div>
);
}
The public ID of the video
Permissions: Public - no authentication required.
Implementation: convex/comments.ts:253
createForShareGrant
Creates a comment on a video accessed via share grant token.
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
function SharedVideoCommentBox({ grantToken, currentTime }) {
const createComment = useMutation(api.comments.createForShareGrant);
const [text, setText] = useState('');
const handleSubmit = async () => {
await createComment({
grantToken,
text,
timestampSeconds: currentTime,
});
setText('');
};
return (
<div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Comment at ${currentTime.toFixed(1)}s`}
/>
<button onClick={handleSubmit}>Post Comment</button>
</div>
);
}
Video timestamp in seconds
Parent comment ID for replies (optional)
ID of the created comment
Permissions: Requires valid share grant token
Implementation: convex/comments.ts:115
getThreadedForShareGrant
Retrieves threaded comments for a video accessed via share grant.
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
function SharedVideoComments({ grantToken }) {
const threads = useQuery(api.comments.getThreadedForShareGrant, { grantToken });
return (
<div>
{threads?.map(thread => (
<CommentThread key={thread._id} thread={thread} />
))}
</div>
);
}
Permissions: Requires valid share grant token
Implementation: convex/comments.ts:275
Usage Examples
import { useQuery, useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
import { useState } from 'react';
function VideoCommentsPanel({ videoId, currentTime }) {
const threads = useQuery(api.comments.getThreaded, { videoId });
const createComment = useMutation(api.comments.create);
const toggleResolved = useMutation(api.comments.toggleResolved);
const [newCommentText, setNewCommentText] = useState('');
const [replyTo, setReplyTo] = useState<string | null>(null);
const handleCreateComment = async () => {
await createComment({
videoId,
text: newCommentText,
timestampSeconds: currentTime,
parentId: replyTo || undefined,
});
setNewCommentText('');
setReplyTo(null);
};
return (
<div>
<div className="new-comment">
<textarea
value={newCommentText}
onChange={(e) => setNewCommentText(e.target.value)}
placeholder={replyTo ? 'Write a reply...' : `Comment at ${currentTime}s`}
/>
<button onClick={handleCreateComment}>Post</button>
{replyTo && <button onClick={() => setReplyTo(null)}>Cancel Reply</button>}
</div>
<div className="comments">
{threads?.map(thread => (
<div key={thread._id} className="thread">
<div className="main-comment">
<img src={thread.userAvatarUrl} alt={thread.userName} />
<div>
<strong>{thread.userName}</strong>
<span>{thread.timestampSeconds}s</span>
<p>{thread.text}</p>
<button onClick={() => setReplyTo(thread._id)}>Reply</button>
<button onClick={() => toggleResolved({ commentId: thread._id })}>
{thread.resolved ? 'Unresolve' : 'Resolve'}
</button>
</div>
</div>
{thread.replies.map(reply => (
<div key={reply._id} className="reply">
<img src={reply.userAvatarUrl} alt={reply.userName} />
<div>
<strong>{reply.userName}</strong>
<p>{reply.text}</p>
</div>
</div>
))}
</div>
))}
</div>
</div>
);
}
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
function VideoTimeline({ videoId, duration }) {
const comments = useQuery(api.comments.list, { videoId });
// Group comments by timestamp
const commentsByTime = comments?.reduce((acc, comment) => {
const time = Math.floor(comment.timestampSeconds);
if (!acc[time]) acc[time] = [];
acc[time].push(comment);
return acc;
}, {} as Record<number, typeof comments>);
return (
<div className="timeline">
{Object.entries(commentsByTime || {}).map(([time, commentsAtTime]) => (
<div
key={time}
className="comment-marker"
style={{ left: `${(parseInt(time) / duration) * 100}%` }}
title={`${commentsAtTime.length} comments at ${time}s`}
/>
))}
</div>
);
}
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
import { useState } from 'react';
function FilteredComments({ videoId }) {
const threads = useQuery(api.comments.getThreaded, { videoId });
const [filter, setFilter] = useState<'all' | 'unresolved' | 'resolved'>('all');
const filtered = threads?.filter(thread => {
if (filter === 'all') return true;
if (filter === 'resolved') return thread.resolved;
if (filter === 'unresolved') return !thread.resolved;
});
return (
<div>
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">All Comments</option>
<option value="unresolved">Unresolved</option>
<option value="resolved">Resolved</option>
</select>
{filtered?.map(thread => <CommentThread key={thread._id} thread={thread} />)}
</div>
);
}