POWR/lib/services/NostrBandService.ts
DocNR e6f1677d2c fix(android): prevent profile screen hanging with timeout and fallbacks
- Adds 5-second timeout for Android API calls to NostrBand with AbortController
- Implements platform-specific settings for React Query with longer caches on Android
- Creates error recovery with fallback values instead of empty UI states
- Fixes memory leaks with proper component mount tracking via useRef
- Adds Android-specific safety timeouts to force-refresh unresponsive screens
- Prevents hook ordering issues with consistent hook calling patterns
- Enhances error handling with dedicated error boundaries and recovery UI
- Adds comprehensive documentation for the Android profile optimizations
2025-04-04 18:00:20 -04:00

167 lines
5.6 KiB
TypeScript

// lib/services/NostrBandService.ts
import { nip19 } from 'nostr-tools';
import { createLogger, enableModule } from '@/lib/utils/logger';
import { Platform } from 'react-native';
// Enable logging
enableModule('NostrBandService');
const logger = createLogger('NostrBandService');
const platform = Platform.OS === 'ios' ? 'iOS' : 'Android';
export interface ProfileStats {
pubkey: string;
followersCount: number;
followingCount: number;
isLoading: boolean;
error: Error | null;
}
interface NostrBandProfileStatsResponse {
stats: {
[pubkey: string]: {
pubkey: string;
followers_pubkey_count?: number;
pub_following_pubkey_count?: number;
// Add other fields as needed from the API response
};
};
}
/**
* Service for interacting with the NostrBand API
* This service provides methods to fetch statistics from nostr.band
*/
export class NostrBandService {
private readonly apiUrl = 'https://api.nostr.band';
private cacheDisabled = true; // Disable any internal caching
/**
* Fetches profile statistics from nostr.band API
* @param pubkey Pubkey in hex format or npub format
* @param forceFresh Whether to bypass any caching with a cache-busting parameter
* @returns Promise with profile stats
*/
async fetchProfileStats(pubkey: string, forceFresh: boolean = true): Promise<ProfileStats> {
try {
// Always log request details
logger.info(`[${platform}] Fetching profile stats for pubkey: ${pubkey.substring(0, 8)}...`);
// Check if pubkey is npub or hex and convert if needed
let hexPubkey = pubkey;
if (pubkey.startsWith('npub')) {
try {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
hexPubkey = decoded.data as string;
}
} catch (error) {
logger.error(`[${platform}] Error decoding npub:`, error);
throw new Error('Invalid npub format');
}
}
// Always force cache busting
const cacheBuster = `?_t=${Date.now()}`;
const endpoint = `/v0/stats/profile/${hexPubkey}`;
const url = `${this.apiUrl}${endpoint}${cacheBuster}`;
logger.info(`[${platform}] Fetching from: ${url}`);
// Create AbortController with timeout for Android
const controller = new AbortController();
const timeoutId = setTimeout(() => {
if (platform === 'Android') {
logger.warn(`[Android] Aborting stats fetch due to timeout for ${hexPubkey.substring(0, 8)}`);
controller.abort();
}
}, 5000); // 5 second timeout for Android
try {
// Fetch with explicit no-cache headers and abort signal
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
logger.error(`[${platform}] API error: ${response.status} - ${errorText}`);
throw new Error(`API error: ${response.status} - ${errorText}`);
}
// Parse with timeout protection
const textData = await response.text();
const data = JSON.parse(textData) as NostrBandProfileStatsResponse;
// Check if we got valid data
if (!data || !data.stats || !data.stats[hexPubkey]) {
logger.error(`[${platform}] Invalid response from API:`, JSON.stringify(data));
// Special handling for Android - return fallback values rather than throwing
if (platform === 'Android') {
logger.warn(`[Android] Using fallback stats for ${hexPubkey.substring(0, 8)}`);
return {
pubkey: hexPubkey,
followersCount: 0,
followingCount: 0,
isLoading: false,
error: null
};
}
throw new Error('Invalid response from API');
}
// Extract relevant stats
const profileStats = data.stats[hexPubkey];
// Create result with real data - ensure we have non-null values
const result = {
pubkey: hexPubkey,
followersCount: profileStats.followers_pubkey_count ?? 0,
followingCount: profileStats.pub_following_pubkey_count ?? 0,
isLoading: false,
error: null
};
// Log the fetched stats
logger.info(`[${platform}] Fetched stats for ${hexPubkey.substring(0, 8)}:`, {
followersCount: result.followersCount,
followingCount: result.followingCount
});
return result;
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
// Log the error with platform info
logger.error(`[${platform}] Error fetching profile stats:`, error);
// For Android, return fallback values rather than throwing
if (platform === 'Android') {
logger.warn(`[Android] Returning fallback stats for ${pubkey.substring(0, 8)} due to error`);
return {
pubkey: pubkey,
followersCount: 0,
followingCount: 0,
isLoading: false,
error: error instanceof Error ? error : new Error(String(error))
};
}
// For other platforms, throw to allow React Query to handle retries
throw error;
}
}
}
// Export a singleton instance
export const nostrBandService = new NostrBandService();