npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hamzasaleemorg/convex-comments

v1.0.2

Published

A full-featured comments component for Convex with threads, mentions, reactions, and real-time typing indicators.

Readme

Convex Comments Component

npm version npm downloads License

A comments system for Convex with threads, mentions, reactions, and typing indicators. Includes backend functions and optional React UI components.

Installation

npm install @hamzasaleemorg/convex-comments

Add the component to your Convex app:

// convex/convex.config.ts
import { defineApp } from "convex/server";
import comments from "@hamzasaleemorg/convex-comments/convex.config.js";

const app = defineApp();
app.use(comments);

export default app;

Quick Start (5 minutes)

Get comments working in your Convex app:

1. Create backend functions:

// convex/comments.ts
import { v } from "convex/values";
import { Comments } from "@hamzasaleemorg/convex-comments";
import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";

const comments = new Comments(components.comments);

/**
 * Get or create the comment thread for a document.
 * This is the "Zero-to-One" function that handles the initial setup.
 */
export const getThreadForDocument = mutation({
  args: { documentId: v.string() },
  handler: async (ctx, args) => {
    // 1. Ensure the "Zone" (container) exists for this doc
    const zoneId = await comments.getOrCreateZone(ctx, { 
      entityId: args.documentId 
    });

    // 2. Ensure a "General" thread exists in that zone
    const threadId = await comments.getOrCreateThread(ctx, { zoneId });
    
    return threadId;
  },
});

// Add a comment to a thread
export const addComment = mutation({
  args: { threadId: v.string(), userId: v.string(), body: v.string() },
  handler: async (ctx, args) => {
    return await comments.addComment(ctx, {
      threadId: args.threadId,
      authorId: args.userId,
      body: args.body,
    });
  },
});

// Get messages for the UI
export const getMessages = query({
  args: { threadId: v.string() },
  handler: async (ctx, args) => {
    return await comments.getMessages(ctx, { threadId: args.threadId });
  },
});

2. Use in React:

import { useMutation, useQuery } from "convex/react";
import { useEffect, useState } from "react";
import { api } from "../convex/_generated/api";

function CommentSection({ documentId, userId }) {
  const [threadId, setThreadId] = useState<string | null>(null);
  const setupThread = useMutation(api.comments.getThreadForDocument);
  
  // 1. Initialize the thread for this document
  useEffect(() => {
    setupThread({ documentId }).then(setThreadId);
  }, [documentId]);

  // 2. Load the messages once we have a threadId
  const messages = useQuery(
    api.comments.getMessages, 
    threadId ? { threadId } : "skip"
  );
  const addComment = useMutation(api.comments.addComment);

  if (!threadId || !messages) return <div>Loading...</div>;

  return (
    <div className="comment-section">
      <h3>Comments ({messages.messages.length})</h3>
      <div className="message-list">
        {messages.messages.map((msg) => (
          <div key={msg.message._id}>
            <strong>{msg.message.authorId}:</strong> {msg.message.body}
          </div>
        ))}
      </div>
      
      <button onClick={() => 
        addComment({ threadId, userId, body: "Hello world!" })
      }>
        Post Comment
      </button>
    </div>
  );
}

That's it! You now have a functional comment thread for any entity in your application.


🛠️ The "Standard" Setup

In most applications, you want to show a single list of comments for a specific page or resource. The pattern above is the fastest way to achieve this using our getOrCreate helpers:

  1. Entity ID: Use your existing postId, docId, or pageId.
  2. getOrCreateZone: Maps your ID to the component's internal storage.
  3. getOrCreateThread: Ensures there's a conversation ready to receive messages.

If you are building something more complex, like positioned comments (annotations on a PDF or a canvas), see the Positioned Comments section.

See below for the complete API reference and React components.

Data Model

The component organizes comments into three levels:

  • Zones - Containers for threads, tied to your entities (documents, tasks, etc.)
  • Threads - Groups of messages within a zone
  • Messages - Individual comments with mentions, reactions, and attachments

Each message can have:

  • Body text with automatic mention and link parsing
  • Attachments (URLs, files, images)
  • Emoji reactions
  • Resolved state
  • Edit history

Backend Usage

Method 1: Comments Class

The recommended approach. Provides type-safe methods and optional callbacks.

import { Comments } from "@hamzasaleemorg/convex-comments";
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";

const comments = new Comments(components.comments);

export const createZone = mutation({
  args: { entityId: v.string() },
  handler: async (ctx, args) => {
    return await comments.getOrCreateZone(ctx, {
      entityId: args.entityId,
    });
  },
});

export const addComment = mutation({
  args: { threadId: v.string(), body: v.string() },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    return await comments.addComment(ctx, {
      threadId: args.threadId,
      authorId: userId,
      body: args.body,
    });
  },
});

Method 2: Direct Component Calls

Call component functions directly through ctx.runMutation or ctx.runQuery.

import { components } from "./_generated/api";

export const addComment = mutation({
  args: { threadId: v.string(), body: v.string() },
  handler: async (ctx, args) => {
    return await ctx.runMutation(components.comments.lib.addComment, {
      threadId: args.threadId,
      authorId: await getAuthUserId(ctx),
      body: args.body,
    });
  },
});

Method 3: Exposed API

Generate wrapper functions for frontend use.

import { exposeApi } from "@hamzasaleemorg/convex-comments";
import { components } from "./_generated/api";

export const {
  getThreads,
  addThread,
  addComment,
  toggleReaction,
  setIsTyping,
  getTypingUsers,
} = exposeApi(components.comments, {
  auth: async (ctx, operation) => {
    return await getAuthUserId(ctx);
  },
});

API Reference

Zones

getOrCreateZone(ctx, args)

  • Args: { entityId: string, metadata?: any }
  • Returns: Id<"zones">
  • Creates a zone if it doesn't exist, otherwise returns existing zone

getZone(ctx, args)

  • Args: { entityId: string }
  • Returns: Zone or null
  • Get zone by entity ID

deleteZone(ctx, args)

  • Args: { zoneId: Id<"zones"> }
  • Deletes zone and all threads/messages within it

Threads

addThread(ctx, args)

  • Args: { zoneId: Id<"zones">, position?: { x: number, y: number, anchor?: string }, metadata?: any }
  • Returns: Id<"threads">
  • Creates a new thread in the zone
  • The position field is optional - use it for positioned comments (document editors, design tools, video timestamps). See #positioned-comments-optional for examples.

getThreads(ctx, args)

  • Args: { zoneId: Id<"zones">, limit?: number, cursor?: string, includeResolved?: boolean }
  • Returns: { threads: Thread[], nextCursor?: string, hasMore: boolean }
  • Lists threads with first message preview and pagination

resolveThread(ctx, args)

  • Args: { threadId: Id<"threads">, userId: string }
  • Marks thread as resolved

unresolveThread(ctx, args)

  • Args: { threadId: Id<"threads"> }
  • Reopens a resolved thread

deleteThread(ctx, args)

  • Args: { threadId: Id<"threads"> }
  • Deletes thread and all messages

Messages

addComment(ctx, args)

  • Args: { threadId: Id<"threads">, authorId: string, body: string, attachments?: Attachment[] }
  • Returns: { messageId: Id<"messages">, mentions: Mention[], links: Link[] }
  • Creates message and parses mentions/links automatically

getMessages(ctx, args)

  • Args: { threadId: Id<"threads">, limit?: number, cursor?: string, currentUserId?: string }
  • Returns: { messages: Message[], nextCursor?: string, hasMore: boolean }
  • Lists messages with reactions, supports pagination

editMessage(ctx, args)

  • Args: { messageId: Id<"messages">, body: string, authorId?: string }
  • Updates message body

deleteMessage(ctx, args)

  • Args: { messageId: Id<"messages">, authorId?: string }
  • Soft deletes message (marks as deleted, preserves data)

Reactions

toggleReaction(ctx, args)

  • Args: { messageId: Id<"messages">, userId: string, emoji: string }
  • Returns: { added: boolean }
  • Adds reaction if not present, removes if already exists

addReaction(ctx, args)

  • Args: { messageId: Id<"messages">, userId: string, emoji: string }
  • Adds reaction (idempotent)

removeReaction(ctx, args)

  • Args: { messageId: Id<"messages">, userId: string, emoji: string }
  • Removes reaction

getReactions(ctx, args)

  • Args: { messageId: Id<"messages">, currentUserId?: string }
  • Returns grouped reactions with counts and user lists

Typing Indicators

setIsTyping(ctx, args)

  • Args: { threadId: Id<"threads">, userId: string, isTyping: boolean }
  • Sets typing state, automatically expires after 3 seconds

getTypingUsers(ctx, args)

  • Args: { threadId: Id<"threads">, excludeUserId?: string }
  • Returns list of users currently typing (filters expired)

clearUserTyping(ctx, args)

  • Args: { userId: string }
  • Clears all typing indicators for user

React Components

Optional UI components for displaying comments.

import {
  CommentsProvider,
  Comments,
  Thread,
  Comment,
  AddComment,
} from "@hamzasaleemorg/convex-comments/react";

function App() {
  return (
    <CommentsProvider
      userId={currentUser.id}
      resolveUser={(id) => ({ name: users[id].name })}
      reactionChoices={["👍", "❤️", "😄", "🎉"]}
    >
      <Comments threads={threads} />
    </CommentsProvider>
  );
}

CommentsProvider

Required wrapper that provides configuration to child components.

Props:

  • userId: string | null - Current user ID
  • resolveUser?: (userId: string) => Promise<{ name: string, avatar?: string }> - Function to fetch user details
  • reactionChoices?: string[] - Available emoji reactions
  • canModerate?: boolean - Whether user can moderate comments
  • styles?: CommentsStyles - Custom styling

Comments

Displays list of threads.

Props:

  • threads: Thread[] - Array of threads to display
  • hasMore?: boolean - Whether more threads exist
  • onThreadClick?: (threadId: string) => void - Thread click handler
  • onNewThread?: () => void - New thread button handler

Thread

Displays single thread with messages.

Props:

  • thread: Thread - Thread data
  • messages: Message[] - Array of messages
  • typingUsers?: TypingUser[] - Users currently typing
  • onSubmit?: (body: string) => void - Submit handler
  • onToggleReaction?: (messageId: string, emoji: string) => void
  • onResolve?: () => void

Comment

Displays single message.

Props:

  • comment: Message - Message data
  • mine?: boolean - Whether current user authored message
  • onToggleReaction?: (emoji: string) => void
  • onEdit?: (newBody: string) => void
  • onDelete?: () => void

AddComment

Message composer with mention autocomplete.

Props:

  • onSubmit?: (body: string, attachments?: Attachment[]) => void
  • onTypingChange?: (isTyping: boolean) => void
  • mentionableUsers?: MentionableUser[] - Users for autocomplete
  • placeholder?: string
  • allowEditing?: boolean

Positioned Comments (Optional)

The position field in addThread() is an optional feature for anchoring comments to specific locations. This is useful for:

  • Document editors (comment on specific paragraphs)
  • Design tools (comment at x/y coordinates on canvas)
  • Video players (comment at specific timestamps)
  • Code review (comment on specific line numbers)

Position Object

position?: {
  x: number;        // X coordinate (pixels, percentage, line number, etc.)
  y: number;        // Y coordinate (pixels, percentage, timestamp, etc.)
  anchor?: string;  // Optional identifier (element ID, paragraph, filename)
}

When to Use Position

Use positioned comments if:

  • Your UI needs to show comments at specific visual locations
  • You want to anchor threads to content that can move (paragraphs, code blocks)
  • You're building collaborative editing tools
  • Comments need to appear as overlays or annotations

Skip position if:

  • You only need general discussions (like GitHub issue comments)
  • All comments appear in a single list/feed
  • Location doesn't matter for your use case

Examples

Document editor (like Google Docs):

await comments.addThread(ctx, {
  zoneId,
  position: {
    x: 120,              // Pixels from left
    y: 450,              // Pixels from top
    anchor: "para-3"     // Paragraph identifier
  }
});

Design tool (like Figma):

await comments.addThread(ctx, {
  zoneId,
  position: {
    x: 500,              // Canvas X coordinate
    y: 300,              // Canvas Y coordinate
    anchor: "layer-5"    // Layer name
  }
});

Video player:

await comments.addThread(ctx, {
  zoneId,
  position: {
    x: 0,                // Not used for video
    y: 125,              // Timestamp in seconds
    anchor: "timecode"   // Indicates this is a timestamp
  }
});

Code review:

await comments.addThread(ctx, {
  zoneId,
  position: {
    x: 0,                    // Not used
    y: 42,                   // Line number
    anchor: "src/main.ts"    // File path
  }
});

No Position Needed

For simple comment threads (like chat, issue tracking, general discussions), just omit the position field:

// Simple thread without position
await comments.addThread(ctx, { zoneId });

Key Point: The component stores position data but doesn't render it. Your UI decides how to display positioned threads based on the stored coordinates.

Mention Parsing

Mentions use @userId format and are parsed automatically when creating messages.

Supported characters in user IDs:

  • Letters and numbers
  • Underscores, hyphens, colons

Examples:

  • @alice
  • @user_123
  • @clerk:user_abc

The addComment function returns parsed mentions with their positions in the text:

{
  mentions: [
    { userId: "alice", start: 0, end: 6 },
    { userId: "bob", start: 11, end: 15 }
  ]
}

Attachments

Messages support attachments with metadata:

await comments.addComment(ctx, {
  threadId: "...",
  authorId: "...",
  body: "Attached files",
  attachments: [
    {
      type: "image",
      url: "https://example.com/image.png",
      name: "Screenshot.png",
      mimeType: "image/png",
      size: 145678,
    },
    {
      type: "file",
      url: "https://example.com/doc.pdf",
      name: "Document.pdf",
    },
  ],
});

Supported types: "url", "file", "image"

Callbacks

The Comments class accepts optional callbacks for notifications:

const comments = new Comments(components.comments, {
  onNewMessage: async ({ messageId, threadId, authorId, body, mentions }) => {
    // Send notification about new message
  },
  onMention: async ({ messageId, mentionedUserId, authorId, body }) => {
    // Send notification to mentioned user
  },
});

Both callbacks are triggered automatically when messages are created through the Comments class methods.

HTTP Routes

Expose comments data through HTTP endpoints:

// convex/http.ts
import { httpRouter } from "convex/server";
import { registerRoutes } from "@hamzasaleemorg/convex-comments";
import { components } from "./_generated/api";

const http = httpRouter();
registerRoutes(http, components.comments, {
  pathPrefix: "/api/comments",
});

export default http;

Endpoints:

  • GET /api/comments/zones?entityId=...
  • GET /api/comments/threads?zoneId=...
  • GET /api/comments/messages?threadId=...

Development

npm install
npm run dev

Runs:

  • Component build watcher
  • Example app with Vite and Convex dev

Testing

npm test

License

Apache-2.0