@samthomson/nostr-messaging
v0.17.1
Published
Reusable Nostr messaging system with NIP-04 and NIP-17 support
Maintainers
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-messagingQuick 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) andmedia-blobs(decrypted media) - React Context:
DMProviderfor 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:
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.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 innode_modulesand the layout can collapse to one column. Add the package tocontentin your Tailwind config:// tailwind.config.js or tailwind.config.ts content: [ // ... your paths "./node_modules/@samthomson/nostr-messaging/dist/**/*.js", ],Vitest configuration (if running tests)
By default, Vitest doesn't processnode_modulesthrough your bundler, so the package's@/imports won't resolve in tests. Add this to yourvite.config.ts:test: { // ... other test config server: { deps: { inline: ['@samthomson/nostr-messaging'], }, }, },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.
Default message sounds (when using
DEFAULT_NEW_MESSAGE_SOUNDS)
The package ships default sound files inassets/sounds/. To use them, copy them into your app so they are served at/sounds/. Example: add ascripts/postinstall.shthat runs afternpm install, and call it frompackage.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/ fiIf you use your own sounds instead, pass
config.soundswith 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 passsounds/ui/protocolMode, defaults apply (e.g.ui.isMobile: true). Passui={{ 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 existsBuild
npm install
npm run buildWatch Mode
npm run dev # Rebuilds on changesPublishing
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-messagingLocal 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
