mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-19 19:01:18 +00:00
580 lines
18 KiB
TypeScript
580 lines
18 KiB
TypeScript
// lib/stores/ndk.ts
|
|
// IMPORTANT: 'react-native-get-random-values' must be the first import to ensure
|
|
// proper crypto polyfill application before other libraries are loaded
|
|
import 'react-native-get-random-values';
|
|
import { Platform } from 'react-native';
|
|
import { create } from 'zustand';
|
|
// Using standard NDK types but importing NDKEvent from ndk-mobile for compatibility
|
|
import NDK, { NDKFilter } from '@nostr-dev-kit/ndk';
|
|
import { NDKEvent, NDKUser } from '@nostr-dev-kit/ndk-mobile';
|
|
import * as SecureStore from 'expo-secure-store';
|
|
import * as Crypto from 'expo-crypto';
|
|
import { openDatabaseSync } from 'expo-sqlite';
|
|
import { NDKMobilePrivateKeySigner, generateKeyPair } from '@/lib/mobile-signer';
|
|
|
|
// Constants for SecureStore
|
|
const PRIVATE_KEY_STORAGE_KEY = 'nostr_privkey';
|
|
|
|
// Default relays
|
|
const DEFAULT_RELAYS = [
|
|
'wss://powr.duckdns.org', // Your primary relay
|
|
'wss://relay.damus.io',
|
|
'wss://relay.nostr.band',
|
|
'wss://nos.lol'
|
|
];
|
|
|
|
type NDKStoreState = {
|
|
ndk: NDK | null;
|
|
currentUser: NDKUser | null;
|
|
isLoading: boolean;
|
|
isAuthenticated: boolean;
|
|
error: Error | null;
|
|
relayStatus: Record<string, 'connected' | 'connecting' | 'disconnected' | 'error'>;
|
|
};
|
|
|
|
type NDKStoreActions = {
|
|
init: () => Promise<void>;
|
|
login: (privateKey?: string) => Promise<boolean>;
|
|
logout: () => Promise<void>;
|
|
generateKeys: () => { privateKey: string; publicKey: string; nsec: string; npub: string };
|
|
publishEvent: (kind: number, content: string, tags: string[][]) => Promise<NDKEvent | null>;
|
|
createEvent: (kind: number, content: string, tags: string[][]) => Promise<NDKEvent | null>;
|
|
queueEventForPublishing: (event: NDKEvent) => Promise<boolean>;
|
|
processPublicationQueue: () => Promise<void>;
|
|
fetchEventsByFilter: (filter: NDKFilter) => Promise<NDKEvent[]>;
|
|
};
|
|
|
|
export const useNDKStore = create<NDKStoreState & NDKStoreActions>((set, get) => ({
|
|
// State properties
|
|
ndk: null,
|
|
currentUser: null,
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
error: null,
|
|
relayStatus: {},
|
|
|
|
// Initialize NDK
|
|
init: async () => {
|
|
try {
|
|
console.log('[NDK] Initializing...');
|
|
console.log('NDK init crypto polyfill check:', {
|
|
cryptoDefined: typeof global.crypto !== 'undefined',
|
|
getRandomValuesDefined: typeof global.crypto?.getRandomValues !== 'undefined'
|
|
});
|
|
|
|
set({ isLoading: true, error: null });
|
|
|
|
// Initialize relay status tracking
|
|
const relayStatus: Record<string, 'connected' | 'connecting' | 'disconnected' | 'error'> = {};
|
|
DEFAULT_RELAYS.forEach(r => {
|
|
relayStatus[r] = 'connecting';
|
|
});
|
|
set({ relayStatus });
|
|
|
|
// IMPORTANT: Due to the lack of an Expo config plugin for ndk-mobile,
|
|
// we're using a standard NDK initialization approach rather than trying to use
|
|
// ndk-mobile's native modules, which require a custom build.
|
|
//
|
|
// When an Expo plugin becomes available for ndk-mobile, we can remove this
|
|
// fallback approach and use the initializeNDK() function directly.
|
|
console.log('[NDK] Using standard NDK initialization');
|
|
|
|
// Initialize NDK with relays
|
|
const ndk = new NDK({
|
|
explicitRelayUrls: DEFAULT_RELAYS
|
|
});
|
|
|
|
// Connect to relays
|
|
await ndk.connect();
|
|
|
|
// Setup relay status updates
|
|
DEFAULT_RELAYS.forEach(url => {
|
|
const relay = ndk.pool.getRelay(url);
|
|
if (relay) {
|
|
relay.on('connect', () => {
|
|
set(state => ({
|
|
relayStatus: {
|
|
...state.relayStatus,
|
|
[url]: 'connected'
|
|
}
|
|
}));
|
|
});
|
|
|
|
relay.on('disconnect', () => {
|
|
set(state => ({
|
|
relayStatus: {
|
|
...state.relayStatus,
|
|
[url]: 'disconnected'
|
|
}
|
|
}));
|
|
});
|
|
|
|
// Set error status if not connected within timeout
|
|
setTimeout(() => {
|
|
set(state => {
|
|
if (state.relayStatus[url] === 'connecting') {
|
|
return {
|
|
relayStatus: {
|
|
...state.relayStatus,
|
|
[url]: 'error'
|
|
}
|
|
};
|
|
}
|
|
return state;
|
|
});
|
|
}, 10000);
|
|
}
|
|
});
|
|
|
|
set({ ndk });
|
|
|
|
// Check for saved private key
|
|
const privateKey = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
|
if (privateKey) {
|
|
console.log('[NDK] Found saved private key, initializing signer');
|
|
|
|
try {
|
|
// Create mobile-specific signer with private key
|
|
const signer = new NDKMobilePrivateKeySigner(privateKey);
|
|
ndk.signer = signer;
|
|
|
|
// Get user and profile
|
|
const user = await ndk.signer.user();
|
|
|
|
if (user) {
|
|
console.log('[NDK] User authenticated:', user.pubkey);
|
|
await user.fetchProfile();
|
|
set({
|
|
currentUser: user,
|
|
isAuthenticated: true
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('[NDK] Error initializing with saved key:', error);
|
|
// Remove invalid key
|
|
await SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
|
}
|
|
}
|
|
|
|
// Set up connectivity monitoring to process publication queue
|
|
try {
|
|
const { ConnectivityService } = await import('@/lib/db/services/ConnectivityService');
|
|
|
|
// Process queue on initial connection
|
|
if (ConnectivityService.getInstance().getConnectionStatus()) {
|
|
get().processPublicationQueue();
|
|
}
|
|
|
|
// Add listener to process queue when coming online
|
|
ConnectivityService.getInstance().addListener((isOnline) => {
|
|
if (isOnline) {
|
|
console.log('[NDK] Connection restored, processing publication queue');
|
|
get().processPublicationQueue();
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('[NDK] Error setting up connectivity monitoring:', error);
|
|
}
|
|
|
|
set({ isLoading: false });
|
|
} catch (error) {
|
|
console.error('[NDK] Initialization error:', error);
|
|
set({
|
|
error: error instanceof Error ? error : new Error('Failed to initialize NDK'),
|
|
isLoading: false
|
|
});
|
|
}
|
|
},
|
|
|
|
login: async (privateKey?: string) => {
|
|
set({ isLoading: true, error: null });
|
|
|
|
try {
|
|
const { ndk } = get();
|
|
if (!ndk) {
|
|
throw new Error('NDK not initialized');
|
|
}
|
|
|
|
// If no private key is provided, generate one
|
|
let userPrivateKey = privateKey;
|
|
if (!userPrivateKey) {
|
|
const { privateKey: generatedKey } = get().generateKeys();
|
|
userPrivateKey = generatedKey;
|
|
}
|
|
|
|
// Create mobile-specific signer with private key
|
|
const signer = new NDKMobilePrivateKeySigner(userPrivateKey);
|
|
ndk.signer = signer;
|
|
|
|
// Get user
|
|
const user = await ndk.signer.user();
|
|
if (!user) {
|
|
throw new Error('Could not get user from signer');
|
|
}
|
|
|
|
// Fetch user profile
|
|
console.log('[NDK] Fetching user profile');
|
|
await user.fetchProfile();
|
|
|
|
// Process profile data to ensure image property is set
|
|
if (user.profile) {
|
|
if (!user.profile.image && (user.profile as any).picture) {
|
|
user.profile.image = (user.profile as any).picture;
|
|
}
|
|
|
|
console.log('[NDK] User profile loaded:', user.profile);
|
|
}
|
|
|
|
// Save the private key securely
|
|
await SecureStore.setItemAsync(PRIVATE_KEY_STORAGE_KEY, userPrivateKey);
|
|
|
|
set({
|
|
currentUser: user,
|
|
isAuthenticated: true,
|
|
isLoading: false
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[NDK] Login error:', error);
|
|
set({
|
|
error: error instanceof Error ? error : new Error('Failed to login'),
|
|
isLoading: false
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
|
|
logout: async () => {
|
|
try {
|
|
// Remove private key from secure storage
|
|
await SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
|
|
|
// Reset NDK state
|
|
const { ndk } = get();
|
|
if (ndk) {
|
|
ndk.signer = undefined;
|
|
}
|
|
|
|
// Reset the user state
|
|
set({
|
|
currentUser: null,
|
|
isAuthenticated: false
|
|
});
|
|
|
|
console.log('[NDK] User logged out successfully');
|
|
} catch (error) {
|
|
console.error('[NDK] Logout error:', error);
|
|
}
|
|
},
|
|
|
|
generateKeys: () => {
|
|
try {
|
|
return generateKeyPair();
|
|
} catch (error) {
|
|
console.error('[NDK] Error generating keys:', error);
|
|
set({ error: error instanceof Error ? error : new Error('Failed to generate keys') });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// IMPORTANT: This method uses monkey patching to make event signing work
|
|
// in React Native environment. This is necessary because the underlying
|
|
// Nostr libraries expect Web Crypto API to be available.
|
|
//
|
|
// When ndk-mobile gets proper Expo support, this function can be simplified to:
|
|
// 1. Create the event
|
|
// 2. Call event.sign() directly
|
|
// 3. Call event.publish()
|
|
// without the monkey patching code.
|
|
publishEvent: async (kind: number, content: string, tags: string[][]) => {
|
|
try {
|
|
const { ndk, isAuthenticated, currentUser } = get();
|
|
|
|
if (!ndk) {
|
|
throw new Error('NDK not initialized');
|
|
}
|
|
|
|
if (!isAuthenticated || !currentUser) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
// Create event
|
|
console.log('Creating event...');
|
|
const event = new NDKEvent(ndk);
|
|
event.kind = kind;
|
|
event.content = content;
|
|
event.tags = tags;
|
|
|
|
// MONKEY PATCHING APPROACH:
|
|
// This is needed because the standard NDK doesn't properly work with
|
|
// React Native's crypto implementation. When ndk-mobile adds proper Expo
|
|
// support, this can be removed.
|
|
try {
|
|
// Define custom function for random bytes generation
|
|
const customRandomBytes = (length: number): Uint8Array => {
|
|
console.log('Using custom randomBytes in event signing');
|
|
return (Crypto as any).getRandomBytes(length);
|
|
};
|
|
|
|
// Try to find and override the randomBytes function
|
|
const nostrTools = require('nostr-tools');
|
|
const nobleHashes = require('@noble/hashes/utils');
|
|
|
|
// Backup original functions
|
|
const originalNobleRandomBytes = nobleHashes.randomBytes;
|
|
|
|
// Override with our implementation
|
|
(nobleHashes as any).randomBytes = customRandomBytes;
|
|
|
|
// Sign event
|
|
console.log('Signing event with patched libraries...');
|
|
await event.sign();
|
|
|
|
// Restore original functions
|
|
(nobleHashes as any).randomBytes = originalNobleRandomBytes;
|
|
|
|
console.log('Event signed successfully');
|
|
} catch (signError) {
|
|
console.error('Error signing event:', signError);
|
|
throw signError;
|
|
}
|
|
|
|
// Publish the event
|
|
console.log('Publishing event...');
|
|
await event.publish();
|
|
|
|
console.log('Event published successfully:', event.id);
|
|
return event;
|
|
} catch (error) {
|
|
console.error('Error publishing event:', error);
|
|
console.error('Error details:', error instanceof Error ? error.stack : 'Unknown error');
|
|
set({ error: error instanceof Error ? error : new Error('Failed to publish event') });
|
|
return null;
|
|
}
|
|
},
|
|
|
|
// Create and sign a Nostr event without publishing it
|
|
createEvent: async (kind: number, content: string, tags: string[][]): Promise<NDKEvent | null> => {
|
|
try {
|
|
const { ndk, isAuthenticated, currentUser } = get();
|
|
|
|
if (!ndk) {
|
|
throw new Error('NDK not initialized');
|
|
}
|
|
|
|
if (!isAuthenticated || !currentUser) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
// Create event
|
|
const event = new NDKEvent(ndk);
|
|
event.kind = kind;
|
|
event.content = content;
|
|
event.tags = tags;
|
|
|
|
// Define custom function for random bytes generation
|
|
const customRandomBytes = (length: number): Uint8Array => {
|
|
console.log('Using custom randomBytes in event signing');
|
|
return (Crypto as any).getRandomBytes(length);
|
|
};
|
|
|
|
// Try to find and override the randomBytes function
|
|
const nostrTools = require('nostr-tools');
|
|
const nobleHashes = require('@noble/hashes/utils');
|
|
|
|
// Backup original functions
|
|
const originalNobleRandomBytes = nobleHashes.randomBytes;
|
|
|
|
// Override with our implementation
|
|
(nobleHashes as any).randomBytes = customRandomBytes;
|
|
|
|
// Sign the event but don't publish
|
|
try {
|
|
await event.sign();
|
|
} finally {
|
|
// Restore original functions
|
|
(nobleHashes as any).randomBytes = originalNobleRandomBytes;
|
|
}
|
|
|
|
return event;
|
|
} catch (error) {
|
|
console.error('Error creating event:', error);
|
|
set({ error: error instanceof Error ? error : new Error('Failed to create event') });
|
|
return null;
|
|
}
|
|
},
|
|
|
|
// Queue an event for publishing when online
|
|
queueEventForPublishing: async (event: NDKEvent): Promise<boolean> => {
|
|
try {
|
|
// Only proceed if the event has an ID and signature
|
|
if (!event.id || !event.sig) {
|
|
throw new Error('Event must be signed before queueing');
|
|
}
|
|
|
|
// First cache the event itself
|
|
try {
|
|
const EventCache = (await import('@/lib/db/services/EventCache')).EventCache;
|
|
const db = openDatabaseSync('powr.db');
|
|
const cache = new EventCache(db);
|
|
|
|
// Convert NDKEvent to NostrEvent for caching
|
|
await cache.setEvent({
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
kind: event.kind || 0,
|
|
created_at: event.created_at || Math.floor(Date.now() / 1000),
|
|
content: event.content,
|
|
tags: event.tags.map(tag => tag.map(item => String(item))),
|
|
sig: event.sig
|
|
});
|
|
|
|
// Then add to publication queue
|
|
await db.runAsync(
|
|
`INSERT OR REPLACE INTO publication_queue
|
|
(event_id, attempts, created_at, payload)
|
|
VALUES (?, ?, ?, ?)`,
|
|
[
|
|
event.id,
|
|
0,
|
|
Date.now(),
|
|
JSON.stringify({
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
kind: event.kind,
|
|
created_at: event.created_at,
|
|
content: event.content,
|
|
tags: event.tags,
|
|
sig: event.sig
|
|
})
|
|
]
|
|
);
|
|
} catch (cacheError) {
|
|
console.error('Error caching event:', cacheError);
|
|
// Continue to try publishing even if caching fails
|
|
}
|
|
|
|
// Try to publish immediately if online
|
|
try {
|
|
const ConnectivityService = (await import('@/lib/db/services/ConnectivityService')).ConnectivityService;
|
|
|
|
if (ConnectivityService.getInstance().getConnectionStatus()) {
|
|
try {
|
|
await event.publish();
|
|
|
|
// Remove from queue if successful
|
|
const db = openDatabaseSync('powr.db');
|
|
await db.runAsync(
|
|
`DELETE FROM publication_queue WHERE event_id = ?`,
|
|
[event.id]
|
|
);
|
|
|
|
console.log('Event published successfully:', event.id);
|
|
return true;
|
|
} catch (publishError) {
|
|
console.log('Event queued for later publishing:', event.id);
|
|
return false;
|
|
}
|
|
} else {
|
|
console.log('Event queued for later publishing (offline):', event.id);
|
|
return false;
|
|
}
|
|
} catch (connectivityError) {
|
|
console.error('Error checking connectivity:', connectivityError);
|
|
// Assume offline if connectivity service fails
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error queueing event for publishing:', error);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
// Process the publication queue
|
|
processPublicationQueue: async (): Promise<void> => {
|
|
try {
|
|
const { ndk } = get();
|
|
if (!ndk) return;
|
|
|
|
const db = openDatabaseSync('powr.db');
|
|
|
|
// Get all queued events that haven't exceeded max attempts
|
|
const queuedEvents = await db.getAllAsync<{
|
|
event_id: string;
|
|
attempts: number;
|
|
payload: string;
|
|
}>(
|
|
`SELECT event_id, attempts, payload
|
|
FROM publication_queue
|
|
WHERE attempts < 5
|
|
ORDER BY created_at ASC`
|
|
);
|
|
|
|
console.log(`Processing publication queue: ${queuedEvents.length} events`);
|
|
|
|
for (const item of queuedEvents) {
|
|
try {
|
|
// Update attempt count and timestamp
|
|
await db.runAsync(
|
|
`UPDATE publication_queue
|
|
SET attempts = attempts + 1,
|
|
last_attempt = ?
|
|
WHERE event_id = ?`,
|
|
[Date.now(), item.event_id]
|
|
);
|
|
|
|
// Parse the event from payload
|
|
const eventData = JSON.parse(item.payload);
|
|
|
|
// Create a new NDKEvent
|
|
const event = new NDKEvent(ndk);
|
|
|
|
// Copy properties
|
|
event.id = eventData.id;
|
|
event.pubkey = eventData.pubkey;
|
|
event.kind = eventData.kind;
|
|
event.created_at = eventData.created_at;
|
|
event.content = eventData.content;
|
|
event.tags = eventData.tags;
|
|
event.sig = eventData.sig;
|
|
|
|
// Publish the event
|
|
await event.publish();
|
|
|
|
// Remove from queue on success
|
|
await db.runAsync(
|
|
`DELETE FROM publication_queue WHERE event_id = ?`,
|
|
[item.event_id]
|
|
);
|
|
|
|
console.log(`Published queued event: ${item.event_id}`);
|
|
} catch (error) {
|
|
console.error(`Error publishing queued event ${item.event_id}:`, error);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error processing publication queue:', error);
|
|
}
|
|
},
|
|
|
|
fetchEventsByFilter: async (filter: NDKFilter) => {
|
|
try {
|
|
const { ndk } = get();
|
|
|
|
if (!ndk) {
|
|
throw new Error('NDK not initialized');
|
|
}
|
|
|
|
// Fetch events
|
|
const events = await ndk.fetchEvents(filter);
|
|
|
|
// Convert Set to Array
|
|
return Array.from(events);
|
|
} catch (error) {
|
|
console.error('Error fetching events:', error);
|
|
set({ error: error instanceof Error ? error : new Error('Failed to fetch events') });
|
|
return [];
|
|
}
|
|
}
|
|
})); |