14 KiB
ARCHIVED: NDK Comprehensive Guide for POWR App
ARCHIVED: This document has been superseded by docs/technical/ndk/comprehensive_guide.md as part of the documentation reorganization.
UPDATED FOR MVP: This guide has been consolidated and aligned with our simplified social approach for the MVP release.
This guide combines key information from our various NDK analysis documents and the ndk-mobile package to provide a comprehensive reference for implementing Nostr features in POWR app. It's organized to support our MVP strategy with a focus on the core NDK capabilities we need.
Table of Contents
- NDK Core Concepts
- Proper State Management
- Subscription Lifecycle
- NIP-19 and Encoding/Decoding
- Context Provider Pattern
- Mobile-Specific Considerations
- Best Practices for POWR MVP
NDK Core Concepts
NDK (Nostr Development Kit) provides a set of tools for working with the Nostr protocol. Key components include:
- NDK Instance: Central object for interacting with the Nostr network
- NDKEvent: Represents a Nostr event
- NDKUser: Represents a user (pubkey)
- NDKFilter: Defines criteria for querying events
- NDKSubscription: Manages subscriptions to event streams
- NDKRelay: Represents a connection to a relay
Basic Setup
import NDK from '@nostr-dev-kit/ndk';
// Initialize NDK with relays
const ndk = new NDK({
explicitRelayUrls: [
'wss://relay.damus.io',
'wss://relay.nostr.band'
]
});
// Connect to relays
await ndk.connect();
Proper State Management
The ndk-mobile library emphasizes proper state management using Zustand. This approach is critical for avoiding the issues we've been seeing in our application.
Zustand Store Pattern
// Create a singleton store using Zustand
import { create } from 'zustand';
// Private store instance - not exported
let store: ReturnType<typeof createNDKStore> | undefined;
// Store creator function
const createNDKStore = () => create<NDKState>((set) => ({
ndk: undefined,
initialized: false,
connecting: false,
connectionError: null,
initialize: (config) => set((state) => {
if (state.initialized) return state;
const ndk = new NDK(config);
return { ndk, initialized: true };
}),
connect: async () => {
set({ connecting: true, connectionError: null });
try {
const ndk = store?.getState().ndk;
if (!ndk) throw new Error("NDK not initialized");
await ndk.connect();
set({ connecting: false });
} catch (error) {
set({ connecting: false, connectionError: error });
}
}
}));
// Getter for the singleton store
export const getNDKStore = () => {
if (!store) {
store = createNDKStore();
}
return store;
};
// Hook for components to use
export const useNDKStore = <T>(selector: (state: NDKState) => T) => {
// Ensure the store exists
getNDKStore();
// Return the selected state
return useStore(store)(selector);
};
Critical State Management Points
- Single Store Instance: Always use a singleton pattern for NDK stores
- Lazy Initialization: Only create the store when first accessed
- Proper Selectors: Select only what you need from the store
- Clear State Transitions: Define explicit state transitions (connecting, connected, error)
Subscription Lifecycle
Proper subscription management is crucial for app stability and performance. The subscribe.ts file in ndk-mobile provides advanced subscription handling.
Basic Subscription Pattern
import { useEffect } from 'react';
import { useNDK } from './hooks/useNDK';
function EventComponent({ filter }) {
const { ndk } = useNDK();
const [events, setEvents] = useState([]);
useEffect(() => {
if (!ndk) return;
// Create subscription
const sub = ndk.subscribe(filter, {
closeOnEose: false, // Keep connection open
});
// Handle incoming events
sub.on('event', (event) => {
setEvents(prev => [...prev, event]);
});
// Start subscription
sub.start();
// Critical: Clean up subscription when component unmounts
return () => {
sub.stop();
};
}, [ndk, JSON.stringify(filter)]);
return (/* render events */);
}
Enhanced Subscription Hook
The ndk-mobile package includes an enhanced useSubscribe hook with additional features:
// Example based on ndk-mobile implementation
function useEnhancedSubscribe(filter, options = {}) {
const { ndk } = useNDK();
const [events, setEvents] = useState([]);
const [eose, setEose] = useState(false);
const subRef = useRef(null);
useEffect(() => {
if (!ndk) return;
// Create subscription
const sub = ndk.subscribe(filter, {
closeOnEose: options.closeOnEose || false,
wrap: options.wrap || false
});
subRef.current = sub;
// Handle incoming events
sub.on('event', (event) => {
// Process events (filtering, wrapping, etc.)
// Add to state
setEvents(prev => {
// Check for duplicates using event.id
if (prev.some(e => e.id === event.id)) return prev;
return [...prev, event];
});
});
// Handle end of stored events
sub.on('eose', () => {
setEose(true);
});
// Start subscription
sub.start();
// Clean up
return () => {
if (subRef.current) {
subRef.current.stop();
}
};
}, [ndk, JSON.stringify(filter), options]);
return { events, eose };
}
NIP-19 and Encoding/Decoding
NIP-19 functions are essential for handling Nostr identifiers like npub, note, and naddr.
Decoding NIP-19 Entities
import { nip19 } from '@nostr-dev-kit/ndk';
// Decode any NIP-19 entity (naddr, npub, nsec, note, etc.)
function decodeNIP19(encoded: string) {
try {
const decoded = nip19.decode(encoded);
// decoded.type will be 'npub', 'note', 'naddr', etc.
// decoded.data will contain the data specific to that type
return decoded;
} catch (error) {
console.error('Invalid NIP-19 format:', error);
return null;
}
}
// Convert npub to hex pubkey
function npubToHex(npub: string) {
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
return decoded.data as string; // This is the hex pubkey
}
return null;
} catch (error) {
console.error('Invalid npub format:', error);
return null;
}
}
Encoding to NIP-19 Formats
// Create an npub from a hex public key
function hexToNpub(hexPubkey: string) {
return nip19.npubEncode(hexPubkey);
}
// Create a note (event reference) from event ID
function eventIdToNote(eventId: string) {
return nip19.noteEncode(eventId);
}
// Create an naddr for addressable events
function createNaddr(pubkey: string, kind: number, identifier: string) {
return nip19.naddrEncode({
pubkey, // Hex pubkey
kind, // Event kind (number)
identifier // The 'd' tag value
});
}
Context Provider Pattern
The ndk-mobile package emphasizes the Context Provider pattern for proper NDK integration:
import { NDKProvider } from '@nostr-dev-kit/ndk-mobile';
import App from './App';
// Root component
export default function Root() {
return (
<NDKProvider
params={{
explicitRelayUrls: [
'wss://relay.damus.io',
'wss://relay.nostr.band'
]
}}
>
<App />
</NDKProvider>
);
}
This pattern ensures:
- Single NDK Instance: The entire app shares one NDK instance
- Consistent State: Auth state and relay connections are managed in one place
- Hooks Availability: All NDK hooks (useNDK, useSubscribe, etc.) work correctly
- Proper Cleanup: Connections and subscriptions are managed appropriately
Mobile-Specific Considerations
The ndk-mobile package includes several mobile-specific optimizations:
Caching Strategy
Mobile devices need efficient caching to reduce network usage and improve performance:
import { SQLiteAdapter } from '@nostr-dev-kit/ndk-mobile/cache-adapter/sqlite';
// Initialize NDK with SQLite caching
const ndk = new NDK({
explicitRelayUrls: [...],
cacheAdapter: new SQLiteAdapter({
dbName: 'nostr-cache.db'
})
});
Mobile Signers
For secure key management on mobile devices:
import { SecureStorageSigner } from '@nostr-dev-kit/ndk-mobile/signers/securestorage';
// Use secure storage for private keys
const signer = new SecureStorageSigner({
storageKey: 'nostr-private-key'
});
// Add to NDK
ndk.signer = signer;
Best Practices for POWR MVP
Based on our analysis and the ndk-mobile implementation, here are key best practices for our MVP:
1. State Management
- Use singleton stores for NDK and session state
- Implement proper state machine for auth transitions with Zustand
- Add event listeners/callbacks for components to respond to auth changes
// Authentication state using Zustand (aligned with ndk-mobile patterns)
export const useAuthStore = create((set) => ({
state: 'unauthenticated', // 'unauthenticated', 'authenticating', 'authenticated', 'deauthenticating'
user: null,
error: null,
startAuthentication: async (npub) => {
set({ state: 'authenticating', error: null });
try {
// Authentication logic here
const user = await authenticateUser(npub);
set({ state: 'authenticated', user });
} catch (error) {
set({ state: 'unauthenticated', error });
}
},
logout: async () => {
set({ state: 'deauthenticating' });
// Cleanup logic
set({ state: 'unauthenticated', user: null });
},
}));
2. Subscription Management
- Always clean up subscriptions when components unmount
- Keep subscription references in useRef to ensure proper cleanup
- Use appropriate cache strategies to reduce relay load
- Implement proper error handling for subscription failures
// Example component with proper subscription management
function WorkoutShareComponent({ workoutId }) {
const { ndk } = useNDK();
const [relatedPosts, setRelatedPosts] = useState([]);
const subRef = useRef(null);
useEffect(() => {
if (!ndk) return;
// Create subscription for posts mentioning this workout
const sub = ndk.subscribe({
kinds: [1],
'#e': [workoutId]
}, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
});
subRef.current = sub;
// Handle events
sub.on('event', (event) => {
setRelatedPosts(prev => [...prev, event]);
});
// Start subscription
sub.start();
// Clean up on unmount
return () => {
if (subRef.current) {
subRef.current.stop();
}
};
}, [ndk, workoutId]);
// Component JSX
}
3. Simplified Social Features
For the MVP, we're focusing on:
- Workout sharing: Publishing kind 1301 workout records and kind 1 notes quoting them
- Official feed: Display only POWR official account posts in the social tab
- Static content: Prefer static content over complex real-time feeds
// Example function to share a workout
async function shareWorkout(ndk, workout, caption) {
// First, publish the workout record (kind 1301)
const workoutEvent = new NDKEvent(ndk);
workoutEvent.kind = 1301;
workoutEvent.content = JSON.stringify(workout);
// Add appropriate tags
workoutEvent.tags = [
['d', workout.id],
['title', workout.title],
['duration', workout.duration.toString()]
];
// Publish workout record
const workoutPub = await workoutEvent.publish();
// Then create a kind 1 note quoting the workout
const noteEvent = new NDKEvent(ndk);
noteEvent.kind = 1;
noteEvent.content = caption || `Just completed ${workout.title}!`;
// Add e tag to reference the workout event
noteEvent.tags = [
['e', workoutEvent.id, '', 'quote']
];
// Publish social note
await noteEvent.publish();
return { workoutEvent, noteEvent };
}
4. Error Handling & Offline Support
- Implement graceful fallbacks for network errors
- Store pending publish operations for later retry
- Use SQLite caching for offline access to previously loaded data
// Publication queue service example (aligned with mobile patterns)
class PublicationQueue {
async queueForPublication(event) {
try {
// Try immediate publication
await event.publish();
return true;
} catch (error) {
// Store for later retry
await this.storeEventForLater(event);
return false;
}
}
async publishPendingEvents() {
const pendingEvents = await this.getPendingEvents();
for (const event of pendingEvents) {
try {
await event.publish();
await this.markAsPublished(event);
} catch (error) {
// Keep in queue for next retry
console.error("Failed to publish:", error);
}
}
}
}
5. Provider Pattern
- Use NDKProvider to wrap the application
- Configure NDK once at the app root level
- Access NDK via hooks rather than creating instances
// App.tsx
export default function App() {
return (
<NDKProvider
params={{
explicitRelayUrls: [
'wss://relay.damus.io',
'wss://relay.nostr.band'
]
}}
>
<YourAppComponents />
</NDKProvider>
);
}
// Using NDK in components
function SomeComponent() {
const { ndk } = useNDK();
// Use ndk here
return (/* component JSX */);
}
6. MVP Implementation Focus
For the MVP, focus on implementing:
- Core Authentication: Proper login/logout with state management
- Workout Sharing: Publication of workouts to Nostr
- Limited Social Features: Static feed of official POWR account
- User Profile: Basic user information display
Defer these for post-MVP:
- Full social feed implementation
- Real-time following/interaction
- Complex subscription patterns
By following these best practices, we can create a stable foundation for the POWR app's MVP release that addresses the current architectural issues while providing a simplified but functional social experience.