# Unread Tracking Real-time unread message counter and read receipt tracker with watermark-based read positions, bulk subscriptions, and React hooks. This is an upstream package. It has not been adapted or qualified for Bijection. - Package: convex-unread-tracking - Source: https://github.com/TimpiaAI/convex-unread-tracking - npm: https://www.npmjs.com/package/convex-unread-tracking - Directory: https://bijection.com/components/convex-unread-tracking Read the upstream README for setup, license, and compatibility before using it. ## Upstream package documentation Recorded 2026-10-23. This describes convex-unread-tracking; check the Bijection adaptation separately. A Convex component that tracks read positions and unread message counts per user per channel using watermark-based positioning. It provides real-time unread counters, subscription management, and React hooks with optimistic updates. The component handles message registration/unregistration for accurate counting and supports bulk operations via groups. ### Use cases • **Building chat applications** that need per-channel unread badges and read receipts across multiple conversations • **Team collaboration tools** where users subscribe to different channels and need aggregate unread counts across workspaces • **Notification systems** that track which messages users have seen and provide accurate unread counters in real-time • **Forum or discussion platforms** where users can mute specific senders or archive entire conversation threads • **Multi-tenant applications** where you need to bulk subscribe/unsubscribe entire teams to channels efficiently ### How it works The component uses a watermark-based read position system where `markReadUpTo()` sets a timestamp boundary, and `insertMessage()` registers messages for counting within the same transaction as your message creation. Read positions are stored per user per channel, with support for explicit range overrides via `markReadRange()` and `markOneUnread()`. Setup involves registering the component in `convex.config.ts`, then creating wrapper functions that call the `UnreadTracking` API methods like `getUnreadCount()`, `subscribe()`, and `getTotalUnreadCount()`. The component handles subscription management, sender muting, and channel archiving automatically. The React hooks provide optimistic UI updates - `useMarkAsRead()` immediately shows zero unread count while the mutation syncs, and `useOptimisticUnreadCount()` manages the client-side state. A cleanup utility runs via cron jobs to garbage collect old tracking data. ### Upstream README # convex-unread-tracking Real-time unread message counter and read receipt tracker for [Convex](https://convex.dev). Tracks read positions per user per channel, supports bulk subscriptions via groups, and provides accurate unread counts. Built as a [Convex Component](https://docs.convex.dev/components). ## Features - **Read position tracking** — watermark-based with explicit unread/read range overrides - **Message accounting** — register/unregister messages for accurate unread counts - **Unread counts** — single channel, total across subscriptions, or batch fetch - **Subscription management** — subscribe/unsubscribe/mute senders/archive channels - **Group operations** — bulk subscribe/unsubscribe entire teams - **React hooks** — optimistic mark-as-read with localStorage offline support - **Cleanup utility** — cron-friendly mutation for garbage collection ## Installation ```bash npm install convex-unread-tracking ``` ## Setup ### 1. Register the component ```ts // convex/convex.config.ts import { defineApp } from "convex/server"; import unreadTracking from "convex-unread-tracking/convex.config"; const app = defineApp(); app.use(unreadTracking); export default app; ``` ### 2. Create your backend functions ```ts // convex/unreads.ts import { UnreadTracking } from "convex-unread-tracking"; import { components } from "./_generated/api.js"; import { mutation, query } from "./_generated/server.js"; import { v } from "convex/values"; const unreads = new UnreadTracking(components.unreadTracking); export const sendMessage = mutation({ args: { channelId: v.string(), userId: v.string(), text: v.string() }, handler: async (ctx, args) => { const now = Date.now(); const msgId = await ctx.db.insert("messages", { ...args, timestamp: now }); // Register with unread tracking (same transaction) await unreads.insertMessage(ctx, { channelId: args.channelId, timestamp: now, authorId: args.userId, }); return msgId; }, }); export const markRead = mutation({ args: { userId: v.string(), channelId: v.string(), timestamp: v.number() }, handler: async (ctx, args) => { await unreads.markReadUpTo(ctx, args); }, }); export const getUnreadCount = query({ args: { userId: v.string(), channelId: v.string() }, handler: async (ctx, args) => { return await unreads.getUnreadCount(ctx, args); }, }); ``` ### 3. Subscribe users to channels ```ts export const subscribe = mutation({ args: { userId: v.string(), channelId: v.string() }, handler: async (ctx, args) => { await unreads.subscribe(ctx, args); }, }); ``` ## API Reference ### Read Position Tracking | Method | Description | |--------|-------------| | `markReadUpTo(ctx, { userId, channelId, timestamp })` | Mark everything up to timestamp as read | | `markReadRange(ctx, { userId, channelId, start, end })` | Mark a specific range as read | | `markOneUnread(ctx, { userId, channelId, timestamp })` | Mark a single item as unread | | `getLastRead(ctx, { userId, channelId })` | Get read position for scroll anchor | | `getLastReads(ctx, { userId, channelIds })` | Batch fetch read positions | ### Message Accounting | Method | Description | |--------|-------------| | `insertMessage(ctx, { channelId, timestamp?, authorId? })` | Register a message for unread counting | | `deleteMessage(ctx, { channelId, timestamp })` | Remove a message from tracking | ### Unread Counts | Method | Description | |--------|-------------| | `getUnreadCount(ctx, { userId, channelId })` | Count for a single channel | | `getTotalUnreadCount(ctx, { userId })` | Aggregate across all subscriptions | | `getSingleUnreads(ctx, { userId, channelIds })` | Batch fetch per-channel counts | ### Subscription Management | Method | Description | |--------|-------------| | `subscribe(ctx, { userId, channelId })` | Start tracking unreads | | `unsubscribe(ctx, { userId, channelId })` | Stop tracking (mute) | | `muteSender(ctx, { userId, targetUserId })` | Filter out a sender | | `unmuteSender(ctx, { userId, targetUserId })` | Remove sender filter | | `archive(ctx, { channelId })` | Stop all tracking for a channel | ### Group Operations | Method | Description | |--------|-------------| | `addToGroup(ctx, { groupId, userId })` | Add user to a group | | `removeFromGroup(ctx, { groupId, userId })` | Remove from group | | `subscribeAll(ctx, { groupId, channelId })` | Subscribe entire group | | `unsubscribeAll(ctx, { groupId, channelId })` | Unsubscribe entire group | ### Cleanup ```ts // convex/crons.ts import { cronJobs } from "convex/server"; const crons = cronJobs(); crons.daily("cleanup unreads", { hourUTC: 3, minuteUTC: 0 }, internal.unreads.cleanup); export default crons; ``` ## React Hooks ```tsx import { useMarkAsRead, useOptimisticUnreadCount } from "convex-unread-tracking/react"; function Channel({ channelId }) { const { markRead, getPendingTimestamp, confirmRead } = useMarkAsRead(); // Optimistic count — shows 0 immediately after marking read const serverCount = useQuery(api.unreads.getUnreadCount, { userId, channelId }); const count = useOptimisticUnreadCount(serverCount, getPendingTimestamp(channelId) !== null); const handleMarkRead = async () => { markRead(channelId, Date.now()); // optimistic await markReadMutation({ userId, channelId, timestamp: Date.now() }); confirmRead(channelId); // confirm sync }; } ``` ## Demo [Live Demo](https://unread-tracking-demo.vercel.app) ## License MIT