convex-unread-tracking
v1.1.0
Published
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
Maintainers
Readme
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-trackingSetup
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
