Skip to main content

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>
  );
}
videoId
Id<'videos'>
required
The video to comment on
text
string
required
Comment text content
timestampSeconds
number
required
Video timestamp in seconds where the comment is made
parentId
Id<'comments'>
Parent comment ID for replies (optional)
commentId
Id<'comments'>
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>
  );
}
videoId
Id<'videos'>
required
The video to list comments from
comments
array
Array of comment objects sorted by timestamp
_id
Id<'comments'>
Comment ID
videoId
Id<'videos'>
Associated video ID
userClerkId
string
Clerk user ID of comment author
userName
string
Display name of comment author
userAvatarUrl
string | undefined
Avatar URL of comment author
text
string
Comment text content
timestampSeconds
number
Video timestamp in seconds
parentId
Id<'comments'> | undefined
Parent comment ID (for replies)
resolved
boolean
Whether the comment is resolved
_creationTime
number
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>
  );
}
videoId
Id<'videos'>
required
The video to get threaded comments from
threads
array
Array of top-level comments with nested replies
replies
array
Array 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>
  );
}
commentId
Id<'comments'>
required
The comment to update
text
string
required
New comment text
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>;
}
commentId
Id<'comments'>
required
The comment to delete
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>
  );
}
commentId
Id<'comments'>
required
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>;
}
publicId
string
required
The public ID of the video
text
string
required
Comment text content
timestampSeconds
number
required
Video timestamp in seconds
parentId
Id<'comments'>
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>
  );
}
publicId
string
required
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>
  );
}
grantToken
string
required
The share grant token
text
string
required
Comment text
timestampSeconds
number
required
Video timestamp in seconds
parentId
Id<'comments'>
Parent comment ID for replies (optional)
commentId
Id<'comments'>
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>
  );
}
grantToken
string
required
The share grant token
Permissions: Requires valid share grant token Implementation: convex/comments.ts:275

Usage Examples

Complete Comment System

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>
  );
}

Time-based Comment Markers

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>
  );
}

Filter Resolved/Unresolved Comments

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>
  );
}

Build docs developers (and LLMs) love