@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
- Core Concepts
- Getting Started
- Platform Integrations
- Common Patterns
- Testing
- Troubleshooting
- API Reference
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
- Initialize — Service starts all data sources
- Receive — Data sources emit notifications to the service
- Process — Processor filters and categorizes notifications
- Render — Renderer displays primary notifications, stores chained for later
- Interact — User clicks trigger actions (DISMISS, ACK, POPUP, EXTERNAL_LINK)
- Track — ACK action marks notification as processed
- 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 nextGetting 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.tsKey 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 sourcesKey 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 monitoringKey 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 |
