JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 4
  • Score
    100M100P100Q73622F
  • License MIT

Reusable Nostr messaging system with NIP-04 and NIP-17 support

Package Exports

  • @samthomson/nostr-messaging/core
  • @samthomson/nostr-messaging/package.json
  • @samthomson/nostr-messaging/ui

Readme

@samthomson/nostr-messaging

Reusable Nostr messaging system with NIP-04 and NIP-17 support, IndexedDB caching, and React context for state management.

Built to work with MKStacks.

Installation

npm install @samthomson/nostr-messaging

Quick Start

1. Wrap your app with DMProvider

The package provides a headless React context that manages all messaging state. You need to provide it with dependencies from your app:

import { DMProvider, type DMProviderDeps } from '@samthomson/nostr-messaging/core';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
// ... other hooks

function App() {
  const { nostr } = useNostr();
  const { user } = useCurrentUser();
  // ... gather other dependencies

  return (
    <DMProvider
      children={children}
      nostr={nostr}
      user={user}
      messagingConfig={{ discoveryRelays: [...], relayMode: 'hybrid' }}
      onNotify={(opts) => toast(opts)}
      getDisplayName={getDisplayName}
      fetchAuthorsBatch={useAuthorsBatch}
      publishEvent={publishEvent}
      uploadFile={uploadFile}
      follows={follows}
      sounds={DEFAULT_NEW_MESSAGE_SOUNDS}
      ui={{ showShorts: true, isMobile }}
    />
  );
}

Required: fetchAuthorsBatch
The provider needs profile metadata to show display names in the conversation list and chat. You must pass your app's batch-fetch hook (e.g. useAuthorsBatch) as fetchAuthorsBatch. The provider will call it with the relevant pubkeys; your hook should return { data?: Map } where the map is pubkey -> NostrMetadata or pubkey -> { metadata?: NostrMetadata }. If you use Vite, add resolve.dedupe: ["react", "react-dom"] so the package and app share one React instance; otherwise hook updates may not re-render the provider and names can stay unresolved.

2. Use the messaging hooks

import { useDMContext, useConversationMessages } from '@samthomson/nostr-messaging/core';

function MessagingComponent() {
  const {
    messagingState,
    conversations,
    sendMessage,
    searchMessages,
    // ... many other methods
  } = useDMContext();

  // Get messages for a specific conversation
  const {
    messages,
    loadMore,
    hasMore,
    isLoading,
  } = useConversationMessages('conversationId');

  // Send a message
  await sendMessage({
    conversationId: 'pubkey',
    content: 'Hello!',
    protocol: 'nip17', // or 'nip04'
  });

  return (
    <div>
      {/* Build your UI */}
    </div>
  );
}

Dependencies (peer vs dev)

The package declares @nostrify/nostrify and @nostrify/react in peerDependencies (so the consuming app installs and provides them; the package does not bundle them) and again in devDependencies (so we can build and test the package in isolation). That is intentional: peers are required at runtime from the app; devDeps are only for local npm install and npm run build in the package repo.

What's Included

Core (@samthomson/nostr-messaging/core)

Everything you need for messaging:

  • Pure Functions: Message encryption, conversation management, protocol handling
  • Storage: IndexedDB caching for messages and media. Uses one DB (nostr-dm-cache-v2) with two stores: dm-cache (MessagingState per pubkey) and media-blobs (decrypted media)
  • React Context: DMProvider for state management
  • React Hooks: useDMContext, useConversationMessages
  • Types: Full TypeScript support
  • Constants: DM_PHASES, MESSAGE_PROTOCOL, PROTOCOL_MODE, etc.

Features

  • ✅ NIP-04 (legacy DMs) and NIP-17 (private messages) support
  • ✅ Encrypted file attachments
  • ✅ IndexedDB caching for offline support
  • ✅ Message search (full-text and conversation search)
  • ✅ Unread message tracking
  • ✅ Real-time message sync
  • ✅ Gap filling for message history
  • ✅ Protocol auto-detection and migration

UI export: what the app must provide

If you use @samthomson/nostr-messaging/ui, the package does not bundle its own primitives. Your app must provide them so the package's imports resolve:

  1. Bundler config
    Your app must resolve @/ to your app source (e.g. @/src/). The package's UI code imports from @/components/ui/... and @/lib/utils; those are resolved at build time by your app's bundler.

  2. Tailwind (when using the UI)
    The UI uses Tailwind classes for layout (e.g. two-panel list + chat). Tailwind only scans your app’s source by default, so it won’t see classes in node_modules and the layout can collapse to one column. Add the package to content in your Tailwind config:

    // tailwind.config.js or tailwind.config.ts
    content: [
      // ... your paths
      "./node_modules/@samthomson/nostr-messaging/dist/**/*.js",
    ],
  3. Vitest configuration (if running tests)
    By default, Vitest doesn't process node_modules through your bundler, so the package's @/ imports won't resolve in tests. Add this to your vite.config.ts:

    test: {
      // ... other test config
      server: {
        deps: {
          inline: ['@samthomson/nostr-messaging'],
        },
      },
    },
  4. Components and util
    The package expects these to exist at the paths below. An MKStack/shadcn app already has them; otherwise add them or point @/ so they exist.

Path Exports used
@/lib/utils cn (class-name merge, e.g. clsx + tailwind-merge)
@/components/ui/avatar Avatar, AvatarFallback, AvatarImage
@/components/ui/badge Badge
@/components/ui/button Button
@/components/ui/card Card, CardContent
@/components/ui/dialog Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger
@/components/ui/popover Popover, PopoverContent, PopoverTrigger
@/components/ui/scroll-area ScrollArea
@/components/ui/select Select, SelectContent, SelectItem, SelectTrigger, SelectValue
@/components/ui/separator Separator
@/components/ui/skeleton Skeleton
@/components/ui/tabs Tabs, TabsContent, TabsList, TabsTrigger
@/components/ui/textarea Textarea
@/components/ui/tooltip Tooltip, TooltipContent, TooltipProvider, TooltipTrigger

Install the peer dependencies listed in package.json (React, Radix UI packages, clsx, tailwind-merge, lucide-react, etc.) so your app and the package agree on versions.

  1. Default message sounds (when using DEFAULT_NEW_MESSAGE_SOUNDS)
    The package ships default sound files in assets/sounds/. To use them, copy them into your app so they are served at /sounds/. Example: add a scripts/postinstall.sh that runs after npm install, and call it from package.json:

    "postinstall": "sh scripts/postinstall.sh"

    In the script, copy the package sounds when present:

    if [ -d "node_modules/@samthomson/nostr-messaging/assets/sounds" ]; then
      mkdir -p public/sounds
      cp node_modules/@samthomson/nostr-messaging/assets/sounds/*.mp3 public/sounds/
    fi

    If you use your own sounds instead, pass config.sounds with your own { id, label, url }[] and skip this.

DMProviderProps Interface

interface DMProviderProps {
  children: ReactNode;
  
  // App dependencies
  nostr: NPool;
  user: { pubkey: string; signer: Signer } | null;
  onNotify: (options: NotifyOptions) => void;
  getDisplayName: (pubkey: string, metadata?: NostrMetadata) => string;
  fetchAuthorsBatch: (pubkeys: string[]) => { data?: Map<...> };
  publishEvent: (event: EventTemplate) => Promise<void>;
  uploadFile: (file: File) => Promise<string>;
  follows: string[];
  onReconnected?: () => void;

  // Messaging config (all settings together)
  messagingConfig: MessagingConfig;

  // UI hints
  ui?: {
    showShorts?: boolean;
    showSearch?: boolean;
    isMobile?: boolean;
  };
}

// MessagingConfig type (defined in types.ts)
interface MessagingConfig {
  discoveryRelays: string[];
  relayMode: RelayMode;
  protocolMode?: ProtocolMode;
  renderInlineMedia?: boolean;
  devMode?: boolean;
  appName?: string;
  appDescription?: string;
  soundPref?: {
    options: NewMessageSoundOption[];
    value: NewMessageSoundPref;
    onChange: (pref: NewMessageSoundPref) => void;
  };
}

Network status is tracked inside the package; use isOnline from useDMContext(). Pass onReconnected if you want to run app logic when the package detects reconnection.

DM provider settings (for app settings UI)

Apps should expose these in their own settings and pass the result into messagingConfig. The package does not ship a settings component.

Setting Type Description
discoveryRelays string[] Relay URLs used to discover DM inboxes and query messages. Required.
relayMode 'discovery' | 'hybrid' | 'strict_outbox' How to choose relays: discovery only; user's relays + discovery; or only user's relays (NIP-65/10050). Required.
renderInlineMedia boolean Show images/media inline in messages. Default true.
soundPref { options: NewMessageSoundOption[]; value: { enabled: boolean; soundId: string }; onChange: (pref) => void } New-message sound settings: available options, current selection, and change handler. App owns persistence.
devMode boolean Show decryption/dev UI (e.g. seal payload). Default false.
appName string App name shown in conversation list header.
appDescription string App description shown in conversation list header.
protocolMode ProtocolMode Optional. NIP-04 only, NIP-17 only, or both (default).

Relay list and relay mode affect cache validity; changing them triggers a cold start. Persist discoveryRelays and relayMode (e.g. in app config/localStorage) and pass them into messagingConfig on load.

Default vs custom provider

  • DMProviderDefault: Builds a single props object from its props (user, onNotify, uploadFile, messagingConfig, etc.) and renders <DMProvider {...props} />. If you don't pass sounds/ui/protocolMode, defaults apply (e.g. ui.isMobile: true). Pass ui={{ isMobile: false }} (or your app's value) to override.
  • Custom provider: You pass one props object to DMProvider: messagingConfig, uploadFile, sounds, ui, soundPref, and the rest. No separate "deps" vs "config".

useDMContext API

The main hook provides:

const {
  // State
  messagingState,           // Current loading phase and status
  conversations,            // Map of all conversations
  
  // Actions
  sendMessage,              // Send a message
  deleteMessage,            // Delete a message
  markAsRead,               // Mark conversation as read
  
  // Search
  searchMessages,           // Full-text message search
  searchConversations,      // Search conversations
  
  // File handling
  uploadAndSendFile,        // Upload and send file
  
  // Sync
  forceSync,                // Force sync messages
  
  // ... many more
} = useDMContext();

Development

Local Development with Another Project

If you're working on both the package and a consuming app:

# In the consuming app's parent directory
git clone https://gitlab.com/soapbox-pub/nostr-messaging.git

# In the consuming app
npm install

# The postinstall script will automatically create a symlink
# if it detects ../nostr-messaging exists

Build

npm install
npm run build

Watch Mode

npm run dev  # Rebuilds on changes

Publishing

Apps depend on this package being on the registry (e.g. ^0.3.0). After version bumps, publish so npm i in consuming apps succeeds.

# Log in if needed (token expired / new machine)
npm login

# From this repo: build and publish (scoped package needs public access)
npm run build
npm publish --access public
# If you use 2FA: npm publish --access public --otp=YOUR_CODE

# Verify
npm view @samthomson/nostr-messaging

Local symlink (Silent, Pathos, etc.): Those apps have a postinstall that, when ../nostr-messaging exists, symlinks it into node_modules/@samthomson/nostr-messaging and runs npm run build in the linked package so dist/ and types are up to date. That only runs after npm i succeeds, so the version in the app’s package.json (e.g. ^0.3.0) must exist on the registry first. Publish this package, then in the app run npm i; the symlink will replace the installed copy and use your local build.

License

MIT