Skip to content

TimpiaAI/convex-unread-tracking

Repository files navigation

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

Live Demo

License

MIT

About

Real-time unread message counter and read receipt tracker for Convex

Resources

License

Stars

2 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors