easy-pingv0.8.0

Get in touch

Questions, bug reports, or anything about easy-ping. Either of these reaches me.

Emailteklumo.jembere@gmail.comTelegram@teklumt

For anything others would benefit from, a GitHub issue is better than a DM, because it's searchable.

GitHub

Channels / In-app inbox

Edit this page

In-app inbox

Built into core — cursor pagination, a headless React hook, and seen vs. read as genuinely different states.

Enabling it

No provider needed — it's the one channel core delivers itself, straight into your own database.

channels: { inApp: { enabled: true } },

Seen vs. read

These are two separate pieces of state on every notification, and conflating them is the most common inbox bug:

  • Seen clears the unread badge count. It fires once, in bulk, when the user opens the dropdown — markSeen() / POST /seen.
  • Read marks one specific item, and it stays true until explicitly set. An item can be seen (the badge is gone) but still unread (still rendered bold) until the user actually clicks it.

markRead is idempotent by design: re-marking an already-read notification returns success rather than a 404. Early versions of this made the client's optimistic update roll back and show a just-read item as unread again — worth knowing if you ever call it defensively from more than one place.

useNotifications

const {
  notifications,   // NotificationView[] — currently loaded page(s)
  unseenCount,     // number — server-authoritative
  unreadCount,     // number — derived from what's loaded, not a server total
  nextCursor,
  isLoading,
  error,
  markAsRead,      // (id: string) => Promise<void>
  markAllRead,     // () => Promise<void>
  markSeen,        // () => Promise<void>
  loadMore,        // () => Promise<void>
} = useNotifications();

It keeps itself fresh over the event stream described below, applies optimistic updates for markAsRead/markAllRead/markSeen, and rolls an update back if the request fails.

The hook passes transport, pollIntervalMs, maxPollIntervalMs, safetyNetMs and activeWindowMs straight through to the client.

Without React

useNotifications is a thin wrapper over createNotifyClient, which has no framework dependency — useful for Vue, Svelte, or a vanilla script:

const client = createNotifyClient({
  baseUrl: "/api/notifications",
});

client.subscribe((state) => {
  // state: the same shape useNotifications returns, minus the React bindings
});

client.getTransport(); // { role: "leader" | "follower" | "solo", transport: "sse" | "poll", connected }

How it stays fresh

No socket server, no third-party realtime service. When the first component subscribes, the client does three things:

  1. Elects one tab per browser. It takes a navigator.locks lock named after the mount. The tab that holds it is the leader; every other tab is a follower that mirrors the leader's state over a BroadcastChannel and never talks to the server on its own schedule. Ten open tabs cost one connection. When the leader closes, the lock passes to the next tab, which takes over without a gap.
  2. Opens the event stream. The leader holds GET /events, a server-sent event stream that says ready once and changed whenever this user's inbox moves: after a send(), a /read, a /seen. Nothing else travels on it; the client refetches the first page on changed. While the stream is up, a poll runs only as a safety net every 5 minutes.
  3. Falls back to polling when it must. Some hosts cut long responses (Vercel Hobby at 10 s). If three streams in a row die young, the client stops trying for the session and polls instead. That polling is shaped by activity: pollIntervalMs (15 s) while the user is typing or clicking, doubling towards maxPollIntervalMs (10 min) once they stop, back to the fast interval on the next keypress, focus or return from another tab. A hidden tab does not poll.
createNotifyClient({
  transport: "auto",        // "auto": stream when a DOM exists; "sse": always try; "poll": never stream
  pollIntervalMs: 15_000,   // active-user poll interval, and the fallback base
  maxPollIntervalMs: 600_000,
  safetyNetMs: 300_000,     // poll interval while the stream is connected
  activeWindowMs: 60_000,   // how long after input the fast interval holds
  scope: session.userId,    // only if identity is not a cookie; see below
});
scope

The leader lock and the tab channel are named after the mount, so every tab on the origin joins one group. That is right when one browser is one signed-in user, which a session cookie guarantees. If your app identifies the user some other way, such as a header your custom fetch adds, two tabs signed in as different people would share a leader and mirror each other's inbox. Pass scope (the user id or session id) and each user gets their own group.

transport: "poll" restores the pre-0.6 behaviour exactly. The stream can also be removed on the server with events: false, in which case the client's first attempt fails and it polls.

Whether changed arrives in under a second or in up to 30 depends on the server side: with a cross-process signal (Postgres LISTEN/NOTIFY, a Mongo change stream) a send on any replica reaches every stream at once; without one, each stream compares a cheap fingerprint of the inbox every events.probeIntervalMs. On SQLite or a single replica the in-memory default is already instant.

Piggyback and push relay

Two optional ways to make the bell fresh with no dedicated request at all.

Piggyback on your own API. Every response your app already sends can carry the user's inbox version; the client refreshes only when that number moves.

return Response.json(data, { headers: notify.inboxHeaders(userId) });
const client = createNotifyClient();
const fetchWithBell = client.instrument(fetch); // use this for your app's own calls

Relay from the service worker. If you run the push plugin, the same push that shows an OS notification can wake the open tabs:

import { handlePush } from "easy-ping/sw";
self.addEventListener("push", (event) => handlePush(event));

handlePush shows the notification and posts { source: "easy-ping", type: "changed" } to every window client; the client listens on navigator.serviceWorker and refreshes.

instrument(fetch) only reads the header from responses on the page's own origin. A third-party API the app calls cannot steer the bell.