npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@l.x/notifications

v1.0.5

Published

A platform-agnostic notification service for fetching, processing, storing, and displaying in-app notifications across web, mobile, and extension platforms.

Downloads

259

Readme

@universe/notifications

A platform-agnostic notification service for fetching, processing, storing, and displaying in-app notifications across web, mobile, and extension platforms.

Table of Contents

Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                         NotificationService                             │
│                           (Orchestrator)                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   ┌──────────────────┐     ┌───────────────────┐     ┌──────────────┐  │
│   │   DataSources    │────▶│    Processor      │────▶│   Renderer   │  │
│   │                  │     │                   │     │              │  │
│   │ • Polling (API)  │     │ • Filters tracked │     │ • canRender  │  │
│   │ • Reactive       │     │ • Limits by style │     │ • render()   │  │
│   │ • LocalTrigger   │     │ • Separates chains│     │ • cleanup()  │  │
│   │ • Interval       │     │                   │     │              │  │
│   └──────────────────┘     └───────────────────┘     └──────────────┘  │
│            │                        │                       │          │
│            │                        ▼                       │          │
│            │               ┌───────────────────┐            │          │
│            │               │     Tracker       │◀───────────┘          │
│            │               │                   │                       │
│            │               │ • isProcessed()   │                       │
│            │               │ • track()         │                       │
│            │               │ • cleanup()       │                       │
│            │               └───────────────────┘                       │
│            │                        │                                  │
│            ▼                        ▼                                  │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │                         Telemetry                                │  │
│   │  onNotificationReceived → onNotificationShown → onInteracted    │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

Component Responsibilities

| Component | Responsibility | |-----------|----------------| | DataSource | Feeds notifications to the service (API polling, state subscriptions, local triggers) | | Processor | Filters tracked notifications, enforces style limits, separates primary vs chained | | Renderer | Platform-specific UI rendering with canRender/render/cleanup lifecycle | | Tracker | Tracks acknowledged notifications to prevent re-showing | | Telemetry | Reports lifecycle events (received, shown, interacted) to analytics |

Lifecycle

  1. Initialize — Service starts all data sources
  2. Receive — Data sources emit notifications to the service
  3. Process — Processor filters and categorizes notifications
  4. Render — Renderer displays primary notifications, stores chained for later
  5. Interact — User clicks trigger actions (DISMISS, ACK, POPUP, EXTERNAL_LINK)
  6. Track — ACK action marks notification as processed
  7. Destroy — Service cleans up data sources and active renders

Core Concepts

Notification Types (ContentStyle)

| Style | Description | Max Concurrent | Typical Use | |-------|-------------|----------------|-------------| | MODAL | Full-screen overlays requiring user action | 1 | Feature announcements, onboarding flows | | SYSTEM_BANNER | System alerts (typically at screen bottom) | 1 | Offline status, storage warnings | | LOWER_LEFT_BANNER | Promotional banners stacked in lower-left | 3 | Chain promotions, feature highlights |

Data Source Patterns

The service supports four patterns for feeding notifications:

1. Polling (Backend API via React Query)

Best for: Backend-driven notifications with automatic caching and refetch.

import { createPollingNotificationDataSource, getNotificationQueryOptions } from '@universe/notifications'

const pollingDataSource = createPollingNotificationDataSource({
  queryClient,
  queryOptions: getNotificationQueryOptions({ address, platformType, appId }),
})

2. Reactive (Push-based Subscriptions)

Best for: Instant response to state changes (network status, storage quota).

import { createReactiveDataSource, type ReactiveCondition } from '@universe/notifications'

const offlineCondition: ReactiveCondition<{ isConnected: boolean }> = {
  notificationId: 'local:session:offline',
  subscribe: (onStateChange) => {
    return NetInfo.addEventListener((state) => {
      onStateChange({ isConnected: state.isConnected })
    })
  },
  shouldShow: (state) => state.isConnected === false,
  createNotification: () => new Notification({ /* ... */ }),
}

const reactiveDataSource = createReactiveDataSource({
  condition: offlineCondition,
  tracker,
})

3. LocalTrigger (Condition-based Polling)

Best for: Periodic checks of local state (Redux selectors, app conditions).

import { createLocalTriggerDataSource, type TriggerCondition } from '@universe/notifications'

const backupTrigger: TriggerCondition = {
  id: 'local:backup_reminder',
  shouldShow: async () => {
    const account = selectActiveAccount(getState())
    return account && !hasExternalBackup(account)
  },
  createNotification: () => new Notification({ /* ... */ }),
  onAcknowledge: () => dispatch(setLastSeenTs(Date.now())),
}

const localTriggerDataSource = createLocalTriggerDataSource({
  triggers: [backupTrigger],
  tracker,
  pollIntervalMs: 5000,
})

4. Interval (Simple Periodic Checks)

Best for: Custom periodic notification fetching.

import { createIntervalNotificationDataSource } from '@universe/notifications'

const intervalDataSource = createIntervalNotificationDataSource({
  pollIntervalMs: 30000,
  source: 'legacy_banners',
  logFileTag: 'LegacyBanners',
  getNotifications: async () => fetchLegacyBanners(),
})

Notification Chains

Notifications can trigger follow-up notifications using the POPUP action:

// Step 1: User sees welcome banner
{
  id: 'welcome_step_1',
  content: {
    buttons: [{
      text: 'Learn More',
      onClick: {
        onClick: [OnClickAction.DISMISS, OnClickAction.POPUP],
        onClickLink: 'welcome_step_2'  // ← triggers next notification
      }
    }]
  }
}

// Step 2: Detailed modal (stored as "chained" until triggered)
{
  id: 'welcome_step_2',
  content: { style: ContentStyle.MODAL, /* ... */ }
}

The processor automatically identifies root vs chained notifications using graph analysis.

Notification ID Conventions

| Prefix | Storage | Behavior | |--------|---------|----------| | (none) | API + localStorage | Permanent tracking, synced with backend | | local: | localStorage only | Permanent tracking, local only (no API calls) | | local:session: | sessionStorage | Resets on app restart (e.g., offline banner) |

User Actions (OnClickAction)

| Action | Effect | |--------|--------| | DISMISS | Hides the notification (can reappear if not ACK'd) | | ACK | Marks as acknowledged, prevents re-showing | | POPUP | Shows the notification specified in onClickLink | | EXTERNAL_LINK | Navigates to URL in onClickLink via onNavigate handler |

Actions are combined in arrays and executed sequentially:

onClick: [OnClickAction.DISMISS, OnClickAction.ACK]  // Hide and mark processed
onClick: [OnClickAction.DISMISS, OnClickAction.POPUP]  // Hide and show next

Getting Started

Initialize the Service

import {
  createNotificationService,
  createPollingNotificationDataSource,
  createBaseNotificationProcessor,
  createNotificationTracker,
  createNotificationRenderer,
  createNotificationTelemetry,
} from '@universe/notifications'

const notificationService = createNotificationService({
  dataSources: [
    createPollingNotificationDataSource({ queryClient, queryOptions }),
    // Add more data sources as needed
  ],
  tracker: createNotificationTracker(storageAdapter),
  processor: createBaseNotificationProcessor(tracker),
  renderer: createNotificationRenderer({ onRender, canRender }),
  telemetry: createNotificationTelemetry({ analytics }),
  onNavigate: (url) => window.open(url, '_blank'),
})

await notificationService.initialize()

Handle User Interactions

// When user clicks a button (index 0)
notificationService.onNotificationClick(notificationId, { type: 'button', index: 0 })

// When user clicks the dismiss/close button
notificationService.onNotificationClick(notificationId, { type: 'dismiss' })

// When user clicks the background
notificationService.onNotificationClick(notificationId, { type: 'background' })

// When notification is shown to user (for telemetry)
notificationService.onNotificationShown(notificationId)

// When render fails (e.g., unknown notification style)
notificationService.onRenderFailed(notificationId)

Cleanup

// On unmount or navigation
notificationService.destroy()

Platform Integrations

Web Integration

Location: apps/web/src/notification-service/

apps/web/src/notification-service/
├── WebNotificationService.tsx          # Service initialization
├── createLocalStorageAdapter.ts        # localStorage-based tracker
├── notification-renderer/
│   ├── NotificationContainer.tsx       # Renders all notification types
│   ├── StackedLowerLeftBanners.tsx     # Framer-motion stacking animations
│   ├── notificationStore.ts            # Zustand store for UI state
│   └── components/
│       └── SystemBannerNotification.tsx
├── data-sources/
│   ├── createLegacyBannersNotificationDataSource.ts
│   └── createSystemAlertsDataSource.ts
└── telemetry/
    └── getNotificationTelemetry.ts

Key features:

  • Zustand store pattern for UI state management
  • Framer-motion animations for stacked banners
  • localStorage-based notification tracking

Mobile Integration

Location: apps/mobile/src/notification-service/

apps/mobile/src/notification-service/
├── MobileNotificationServiceManager.tsx  # Service manager component
├── MobileNotificationService.ts          # Service initialization
├── createMobileStorageAdapter.ts         # MMKV-based tracker
├── handleNotificationNavigation.ts       # Navigation handler
├── notification-renderer/
│   ├── NotificationContainer.tsx         # Routes to custom renderers
│   ├── SystemBannerPortal.tsx            # Portal for system banners
│   └── createMobileNotificationRenderer.ts
├── renderers/
│   ├── BackupReminderModalRenderer.tsx   # Custom modal for backup reminder
│   └── OfflineBannerRenderer.tsx         # Custom banner for offline state
├── triggers/
│   ├── backupReminderTrigger.ts          # LocalTrigger example
│   └── createMobileLocalTriggerDataSource.ts
└── data-sources/
    ├── reactive/
    │   └── offlineCondition.ts           # ReactiveCondition example
    └── banners/                          # Legacy banner data sources

Key features:

  • MMKV storage for high-performance tracking
  • Custom renderers for platform-specific UI (BackupReminder, OfflineBanner)
  • NetInfo integration for offline detection via reactive data source

Extension Integration

Location: apps/extension/src/notification-service/

apps/extension/src/notification-service/
├── ExtensionNotificationServiceManager.tsx  # Service manager
├── ExtensionNotificationService.tsx         # Service initialization
├── createChromeStorageAdapter.ts            # Chrome Storage API tracker
├── notification-renderer/
│   ├── NotificationContainer.tsx
│   └── notificationStore.ts
├── renderers/
│   ├── AppRatingModalRenderer.tsx
│   └── StorageWarningModalRenderer.tsx
├── triggers/
│   ├── appRatingTrigger.ts
│   └── createExtensionLocalTriggerDataSource.ts
└── data-sources/
    └── reactive/
        └── storageWarningCondition.ts       # Storage quota monitoring

Key features:

  • Chrome Storage API for cross-session persistence
  • Storage quota monitoring via reactive data source
  • Special navigation handling (e.g., unitag:// protocol)

Common Patterns

Creating a Local Trigger

import { type TriggerCondition } from '@universe/notifications'

export const REMINDER_NOTIFICATION_ID = 'local:my_reminder'

interface CreateReminderTriggerContext {
  getState: () => AppState
  dispatch: (action: AnyAction) => void
}

export function createReminderTrigger(ctx: CreateReminderTriggerContext): TriggerCondition {
  const { getState, dispatch } = ctx

  return {
    id: REMINDER_NOTIFICATION_ID,

    shouldShow: async () => {
      const state = getState()
      const lastSeen = selectReminderLastSeen(state)
      return Date.now() - lastSeen > ONE_DAY_MS
    },

    createNotification: () => new Notification({
      id: REMINDER_NOTIFICATION_ID,
      content: new Content({
        style: ContentStyle.MODAL,
        title: 'Reminder',
        onDismissClick: new OnClick({
          onClick: [OnClickAction.DISMISS, OnClickAction.ACK],
        }),
      }),
    }),

    onAcknowledge: () => {
      dispatch(setReminderLastSeen(Date.now()))
    },
  }
}

Creating a Reactive Condition

import { type ReactiveCondition } from '@universe/notifications'

export const STATUS_NOTIFICATION_ID = 'local:session:status'

interface StatusState {
  isActive: boolean
}

export function createStatusCondition(ctx: { getState: () => AppState }): ReactiveCondition<StatusState> {
  return {
    notificationId: STATUS_NOTIFICATION_ID,

    subscribe: (onStateChange) => {
      // Subscribe to external state changes
      const unsubscribe = someService.addEventListener((event) => {
        onStateChange({ isActive: event.isActive })
      })
      return unsubscribe
    },

    shouldShow: (state) => state.isActive === false,

    createNotification: (state) => new Notification({
      id: STATUS_NOTIFICATION_ID,
      content: new Content({
        style: ContentStyle.SYSTEM_BANNER,
        title: 'Service unavailable',
        onDismissClick: new OnClick({
          onClick: [OnClickAction.DISMISS, OnClickAction.ACK],
        }),
      }),
    }),
  }
}

Custom Renderer with Notification Routing

import { createNotificationRenderer } from '@universe/notifications'

function isMyCustomNotification(notification: InAppNotification): boolean {
  return notification.id === 'local:my_custom'
}

const renderer = createNotificationRenderer({
  canRender: (notification) => {
    // Only one modal at a time
    if (notification.content?.style === ContentStyle.MODAL) {
      return !hasActiveModal()
    }
    return true
  },

  render: (notification) => {
    // Route to appropriate renderer
    if (isMyCustomNotification(notification)) {
      return renderMyCustomModal(notification)
    }
    return renderDefaultNotification(notification)
  },
})

Testing

Mock Data Source

function createMockDataSource(): NotificationDataSource & {
  emit: (notifications: InAppNotification[]) => void
} {
  let callback: ((notifications: InAppNotification[], source: string) => void) | null = null

  return {
    start: (onNotifications) => { callback = onNotifications },
    stop: async () => { callback = null },
    emit: (notifications) => callback?.(notifications, 'mock'),
  }
}

// In tests
const mockDataSource = createMockDataSource()
const service = createNotificationService({
  dataSources: [mockDataSource],
  // ...
})

await service.initialize()
mockDataSource.emit([testNotification])

Mock Tracker

function createMockTracker(): NotificationTracker {
  const processed = new Set<string>()

  return {
    isProcessed: async (id) => processed.has(id),
    getProcessedIds: async () => processed,
    track: async (id) => { processed.add(id) },
    cleanup: async () => { processed.clear() },
  }
}

Mock Renderer

function createMockRenderer(): NotificationRenderer & {
  rendered: InAppNotification[]
} {
  const rendered: InAppNotification[] = []

  return {
    rendered,
    canRender: () => true,
    render: (notification) => {
      rendered.push(notification)
      return () => {
        const index = rendered.indexOf(notification)
        if (index > -1) rendered.splice(index, 1)
      }
    },
  }
}

Troubleshooting

| Issue | Cause | Solution | |-------|-------|----------| | Notification re-appears after dismiss | Using DISMISS without ACK | Add ACK to the onClick array | | Notification never shows | Already tracked as processed | Clear storage or use a new notification ID | | Chained notification not showing | Target not in chained map | Ensure API returns all chain members in same response | | Multiple modals appearing | canRender not checking active modals | Implement modal count check in renderer | | Session notification persists | Using local: instead of local:session: | Use local:session: prefix for session-scoped notifications | | Reactive condition not updating | Not calling onStateChange | Ensure subscribe callback is invoked on state changes |