Unread Tracking
Real-time unread message counter and read receipt tracker with watermark-based read positions, bulk subscriptions, and React hooks.
git clone https://github.com/TimpiaAI/convex-unread-tracking.git
cd convex-unread-trackingThis upstream package has not been adapted or qualified for Bijection. Read the original documentation before adopting it.
Description
Real-time unread message counter and read receipt tracker with watermark-based read positions, bulk subscriptions, and React hooks.
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: TimpiaAI/convex-unread-tracking. License: See repository. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
Real-time unread message counter and read receipt tracker for Convex — tracks read positions, supports bulk subscriptions via groups, and provides accurate unread counts with optimistic client updates
Explore the demo ↗Upstream overview
From the upstream package documentation.
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
From the upstream package documentation.
- 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
From the upstream package documentation.
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 package FAQ
How does the watermark system work for read tracking?
The convex-unread-tracking component uses timestamps as watermarks to mark everything up to a specific time as read via markReadUpTo(). You can override this with explicit ranges using markReadRange() or mark individual messages unread with markOneUnread().
Can I track unread counts across multiple channels at once?
Yes, convex-unread-tracking provides getTotalUnreadCount() for aggregate counts across all user subscriptions and getSingleUnreads() for batch fetching per-channel counts. Both methods work with the subscription management system.
How do I handle team-wide channel subscriptions?
Use the group operations in convex-unread-tracking: addToGroup() to add users to a group, then subscribeAll() or unsubscribeAll() to manage entire team subscriptions to channels in bulk operations.
Does this component handle message cleanup automatically?
Yes, convex-unread-tracking includes a cleanup mutation designed for Convex cron jobs. Set up a daily cron to automatically garbage collect old tracking data and maintain database performance.
How do the React hooks handle offline scenarios?
The convex-unread-tracking React hooks use localStorage to persist optimistic state when offline. Call markRead() for immediate UI updates, then confirmRead() once the server mutation succeeds to sync the optimistic state.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Unread Tracking with Bijection.
Read: https://bijection.com/components/convex-unread-tracking/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from TimpiaAI/convex-unread-tracking, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
convex-unread-tracking
Real-time unread message counter and read receipt tracker for Convex.
Tracks read positions per user per channel, supports bulk subscriptions via groups, and provides accurate unread counts. Built as a Convex Component.
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
npm install convex-unread-tracking
Setup
1. Register the component
// 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
// 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
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
// 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
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
License
MIT