# 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