Naalya Handbook

Notifications

The bell, the pop-up toasts, and the links they point at — how the Hub receives notifications and what each file does.

The API decides what to notify someone about. The Hub decides what the user actually sees. This page covers the Hub half.

Three files do the work:

FileWhat it does
src/components/navigation/notification.indicator.tsxThe bell in the top bar — the badge, the dropdown, "mark all read"
src/hooks/use-notification-toasts.tsxListens for arriving notifications and pops a toast
src/lib/notifications/notification-link.tsWorks out which page a notification should open

Plus the usual query layer in src/queries/notification/.

What happens when a notification arrives

The hook is mounted once, in PresenceProvider, so it is listening everywhere in the app.

The two kinds of message

Both arrive as the same notification event. This trips people up, so here it is plainly.

A new notification has a notificationId. Someone was told something. Show a toast.

A read-state change has every field set to null. It means this same user read something on another device — their phone, another tab. There is nothing new to show; the badge just needs to be recalculated.

src/hooks/use-notification-toasts.tsx
invalidateInbox()

const { notificationId } = event
if (!notificationId) return   // read-state change — nothing more to do

Notice invalidateInbox() runs before the check. Both kinds of message mean the bell is now out of date, so both refresh it. Only the first kind continues on to show a toast.

notificationLink takes a notification's type and data, and returns a route — or null if it cannot build one.

src/lib/notifications/notification-link.ts
case NotificationType.CLASS_TEACHER_ASSIGNED: {
  const classId = asId(data?.classId)
  return classId
    ? {
        label: 'Open class',
        to: '/staff-hub/campus/$campusId/classes/$classId',
        params: { campusId, classId },
      }
    : null
}

Why routes live here and not on the server

The API stores campusId and classId in the notification's data. It never stores a URL.

If it did store /staff-hub/campus/x/classes/y, then the day someone reorganises the Hub's routes, every notification ever sent would point at a dead page — and the API would have no way of knowing. Keeping the route in the Hub means TypeScript and the router catch the rename immediately.

Why every branch checks its ids

The data here arrives over a websocket as a plain object, not a typed one. The reason is technical: the type bus generates one file per source barrel and those files cannot import from each other, so the realtime payload's data is typed as Record<string, unknown>.

So each branch checks what it needs with asId, which returns the value only if it is a non-empty string:

const asId = (value: unknown): string | null =>
  typeof value === 'string' && value.length > 0 ? value : null

If something is missing, the branch returns null — meaning "no link". That is deliberate. A notification with no button is mildly annoying; a button that navigates to a broken URL is worse.

campusId is checked first, for every type, because every staff route is nested under a campus. No campus, no link, no exceptions.

An unhandled type is not an error

If a notification's type has no case, the function reaches its default and returns null. Nothing crashes. The toast simply appears without a button.

Guardian notifications land here on purpose — guardians receive WhatsApp messages and have no Hub pages to open.

The query layer

A standard feature folder — see A Feature End to End. The one piece worth copying is invalidateInbox, shared by every mutation and by the realtime handler:

src/queries/notification/notification.options.ts
const invalidateInbox = () => {
  queryClient.invalidateQueries({ queryKey: notificationQueryKeys.list() })
  queryClient.invalidateQueries({ queryKey: notificationQueryKeys.unreadCount })
}

Two keys, always together. Anything that changes a notification's state changes both the list and the badge, so there is no situation where you would refresh one and not the other.

Notifications do not use registerInvalidation

The Hub has a separate system for cache invalidation — Realtime Invalidation — where the API broadcasts a domain action and the Hub maps it onto query keys.

Notifications do not use it, and that is intentional. That system exists because a write to one thing can invalidate many unrelated caches across the app, and it rides the school-wide channel. Notifications ride a single user's private channel and only ever touch two keys. A four-line helper is enough.

The bell

const { unreadCount } = useUnreadNotificationCount()
// The list is only fetched once the bell is opened.
const { notifications, loadingNotifications } = useNotifications(
  { limit: RECENT_LIMIT },
  open,
)

The count always runs — it drives the badge, which is visible on every page.

The list only runs when the dropdown is open. That second argument is the enabled flag; while open is false, React Query does not fetch. A user who never opens the bell never pays for the list.

Other details:

  • RECENT_LIMIT is 5. The dropdown is a preview, not the full history.
  • The badge shows 9+ above nine, so it cannot stretch the button.
  • Clicking a row marks it read only if it was unread, then navigates if a link resolves.
  • The unread dot is a filled circle when unread and a transparent one when read — same size either way, so rows never shift as they are read.
  • The button's aria-label includes the count ("Notifications, 3 unread"), because the badge itself is invisible to a screen reader.

Where to go next

On this page