npm package: @samthomson/nostr-messaging
Description: Reusable Nostr messaging system with NIP-04 and NIP-17 support
Latest version: 0.12.0

# @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

```bash
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:

```typescript
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

```typescript
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:

   ```js
   // 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`:

   ```typescript
   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.

4. **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`:

   ```json
   "postinstall": "sh scripts/postinstall.sh"
   ```

   In the script, copy the package sounds when present:

   ```sh
   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

```typescript
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:

```typescript
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:

```bash
# 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

```bash
npm install
npm run build
```

### Watch Mode

```bash
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.

```bash
# 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