diff --git a/CHANGELOG.md b/CHANGELOG.md index 84c1dbe..f0c2272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to the POWR project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# Changelog - March 28, 2025 + +## Added +- Real-time follower and following counts in Profile screen + - Integrated with nostr.band API for comprehensive network statistics + - Created NostrBandService for efficient API interaction + - Implemented useProfileStats hook with auto-refresh capabilities + - Added proper loading states and error handling + - Created documentation in the new documentation structure + +## Improved +- Enhanced Profile UI + - Reorganized profile screen layout for better information hierarchy + - Improved npub display with better sharing options + - Added inline copy and QR buttons for better usability + - Enhanced visual consistency across profile elements + - Replaced hardcoded follower counts with real-time data + # Changelog - March 26, 2025 ## Fixed diff --git a/app/(tabs)/profile/overview.tsx b/app/(tabs)/profile/overview.tsx index 8ab49fa..e831e0a 100644 --- a/app/(tabs)/profile/overview.tsx +++ b/app/(tabs)/profile/overview.tsx @@ -1,6 +1,6 @@ // app/(tabs)/profile/overview.tsx import React, { useState, useCallback } from 'react'; -import { View, FlatList, RefreshControl, Pressable, TouchableOpacity, ImageBackground } from 'react-native'; +import { View, FlatList, RefreshControl, Pressable, TouchableOpacity, ImageBackground, Clipboard } from 'react-native'; import { Text } from '@/components/ui/text'; import { Button } from '@/components/ui/button'; import { useNDKCurrentUser } from '@/lib/hooks/useNDK'; @@ -10,6 +10,7 @@ import NostrLoginSheet from '@/components/sheets/NostrLoginSheet'; import EnhancedSocialPost from '@/components/social/EnhancedSocialPost'; import EmptyFeed from '@/components/social/EmptyFeed'; import { useSocialFeed } from '@/lib/hooks/useSocialFeed'; +import { useProfileStats } from '@/lib/hooks/useProfileStats'; import { AnyFeedEntry, WorkoutFeedEntry, @@ -24,6 +25,7 @@ import { QrCode, Mail, Copy } from 'lucide-react-native'; import { useTheme } from '@react-navigation/native'; import type { CustomTheme } from '@/lib/theme'; import { Alert } from 'react-native'; +import { nip19 } from 'nostr-tools'; // Define the conversion function for feed items function convertToLegacyFeedItem(entry: AnyFeedEntry) { @@ -139,6 +141,50 @@ export default function OverviewScreen() { const pubkey = currentUser?.pubkey; + // Profile follower stats component + const ProfileFollowerStats = React.memo(({ pubkey }: { pubkey?: string }) => { + const { followersCount, followingCount, isLoading, error } = useProfileStats({ + pubkey, + refreshInterval: 60000 * 15 // refresh every 15 minutes + }); + + return ( + + + + {isLoading ? '...' : followingCount.toLocaleString()} + following + + + + + + {isLoading ? '...' : followersCount.toLocaleString()} + followers + + + + ); + }); + + // Generate npub format for display + const npubFormat = React.useMemo(() => { + if (!pubkey) return ''; + try { + const npub = nip19.npubEncode(pubkey); + return npub; + } catch (error) { + console.error('Error encoding npub:', error); + return ''; + } + }, [pubkey]); + + // Get shortened npub display version + const shortenedNpub = React.useMemo(() => { + if (!npubFormat) return ''; + return `${npubFormat.substring(0, 8)}...${npubFormat.substring(npubFormat.length - 5)}`; + }, [npubFormat]); + // Handle refresh const handleRefresh = useCallback(async () => { setIsRefreshing(true); @@ -159,14 +205,18 @@ export default function OverviewScreen() { console.log(`Selected ${entry.type}:`, entry); }, []); - // Copy pubkey to clipboard + // Copy npub to clipboard const copyPubkey = useCallback(() => { if (pubkey) { - // Simple alert instead of clipboard functionality - Alert.alert('Pubkey', pubkey, [ - { text: 'OK' } - ]); - console.log('Pubkey shown to user'); + try { + const npub = nip19.npubEncode(pubkey); + Clipboard.setString(npub); + Alert.alert('Copied', 'Public key copied to clipboard in npub format'); + console.log('npub copied to clipboard:', npub); + } catch (error) { + console.error('Error copying npub:', error); + Alert.alert('Error', 'Failed to copy public key'); + } } }, [pubkey]); @@ -238,22 +288,8 @@ export default function OverviewScreen() { style={{ width: 90, height: 90 }} /> - {/* Action buttons - positioned to the right */} - - - - - - - - - + {/* Edit Profile button - positioned to the right */} + router.push('/profile/settings')} @@ -266,24 +302,35 @@ export default function OverviewScreen() { {/* Profile info */} {displayName} - {username} + {username} + + {/* Display npub below username with sharing options */} + {npubFormat && ( + + + {shortenedNpub} + + + + + + + + + )} {/* Follower stats */} - - - - 2,003 - following - - - - - - 4,764 - followers - - - + {/* About text */} {aboutText && ( @@ -295,7 +342,7 @@ export default function OverviewScreen() { - ), [displayName, username, profileImageUrl, aboutText, pubkey, theme.colors.text, router, showQRCode, copyPubkey]); + ), [displayName, username, profileImageUrl, aboutText, pubkey, npubFormat, shortenedNpub, theme.colors.text, router, showQRCode, copyPubkey]); if (loading && entries.length === 0) { return ( diff --git a/docs/features/profile/follower_stats.md b/docs/features/profile/follower_stats.md new file mode 100644 index 0000000..fcd6f56 --- /dev/null +++ b/docs/features/profile/follower_stats.md @@ -0,0 +1,170 @@ +# Profile Follower Statistics + +**Last Updated:** 2025-03-28 +**Status:** Active +**Related To:** Profile Screen, Nostr Integration + +## Purpose + +This document explains the implementation and usage of real-time follower and following statistics in the POWR app's profile screen. This feature enhances the social experience by providing users with up-to-date visibility into their network connections. + +## Feature Overview + +The Profile Follower Statistics feature displays accurate counts of a user's followers and following accounts by integrating with the nostr.band API. This provides users with real-time social metrics directly in their profile view. + +Key capabilities: +- Display of accurate follower and following counts +- Automatic refresh of data at configurable intervals +- Graceful loading states during data fetching +- Error handling for network or API issues +- Proper formatting of large numbers + +## User Experience + +### Profile Screen Integration + +Follower statistics appear in the profile header section, directly below the user's npub display. The statistics are presented in a horizontal layout with the following format: + +``` +[Following Count] following [Followers Count] followers +``` + +For example: +``` +2,351 following 4,764 followers +``` + +The counts are formatted with thousands separators for better readability. + +### Loading States + +When statistics are being loaded for the first time or refreshed, the UI displays a loading indicator instead of numbers: + +``` +... following ... followers +``` + +This provides visual feedback to users that data is being fetched while maintaining layout stability. + +### Refresh Behavior + +Statistics automatically refresh at configurable intervals (default: every 15 minutes) to ensure data remains relatively current without excessive API calls. The refresh is performed in the background without disrupting the user experience. + +### Error Handling + +If an error occurs during data fetching, the UI gracefully falls back to a default state rather than showing error messages to the user. This ensures a clean user experience even when network issues or API limitations are encountered. + +## Technical Implementation + +### Components + +The feature is implemented using the following components: + +1. **ProfileFollowerStats Component**: A React component that displays follower and following counts +2. **useProfileStats Hook**: Provides the data and loading states to the component +3. **NostrBandService**: Service that interfaces with the nostr.band API + +### Implementation Details + +The follower stats component is embedded within the Profile screen and leverages the nostr.band API through our custom hook: + +```tsx +// Profile follower stats component +const ProfileFollowerStats = React.memo(({ pubkey }: { pubkey?: string }) => { + const { followersCount, followingCount, isLoading, error } = useProfileStats({ + pubkey, + refreshInterval: 60000 * 15 // refresh every 15 minutes + }); + + return ( + + + + {isLoading ? '...' : followingCount.toLocaleString()} + following + + + + + + {isLoading ? '...' : followersCount.toLocaleString()} + followers + + + + ); +}); +``` + +### Usage + +The component is used in the profile screen as follows: + +```tsx +// Inside profile screen component + +``` + +## Configuration Options + +The follower statistics feature can be configured with the following options: + +### Refresh Interval + +The time (in milliseconds) between automatic refreshes of the statistics: + +```tsx +// Default: 15 minutes +refreshInterval: 60000 * 15 +``` + +This can be adjusted based on: +- User engagement patterns +- API rate limiting concerns +- Network performance considerations + +## Design Considerations + +### UI Placement and Styling + +The follower statistics are positioned prominently in the profile header but below the user's identity information (name, username, npub) to create a clear visual hierarchy: + +1. User identity (name, username) +2. User identification (npub) +3. Network statistics (following/followers) +4. User bio/about text + +This placement follows common social media patterns where follower counts are important but secondary to identity. + +### Interactions + +While currently implemented as TouchableOpacity components, the follower and following counts are prepared for future enhancements that would allow: +- Tapping on "following" to show a list of accounts the user follows +- Tapping on "followers" to show a list of followers + +These future enhancements will be implemented in subsequent updates. + +## Accessibility + +The follower statistics component is designed with accessibility in mind: + +- Text sizing uses relative units to respect system font size settings +- Color contrast meets WCAG accessibility guidelines +- Component supports screen readers with appropriate text labels + +## Future Enhancements + +Planned enhancements for the follower statistics feature include: + +1. Navigate to follower/following lists when tapping on counts +2. Visual indicator for recent changes in follower counts +3. Offline support with cached follower statistics +4. Enhanced error states with retry options +5. Additional statistics such as post count and engagement metrics + +## References + +- [NostrBandService](../../../lib/services/NostrBandService.ts) - The service implementation +- [useProfileStats](../../../lib/hooks/useProfileStats.ts) - The React hook implementation +- [Profile Overview Screen](../../../app/(tabs)/profile/overview.tsx) - The profile screen implementation +- [nostr.band API Integration](../../technical/nostr/nostr_band_integration.md) - Technical documentation on the API integration diff --git a/docs/technical/nostr/nostr_band_integration.md b/docs/technical/nostr/nostr_band_integration.md new file mode 100644 index 0000000..1ee0fec --- /dev/null +++ b/docs/technical/nostr/nostr_band_integration.md @@ -0,0 +1,211 @@ +# Nostr.band API Integration + +**Last Updated:** 2025-03-28 +**Status:** Active +**Related To:** Profile Stats, NostrBandService, useProfileStats + +## Purpose + +This document outlines the integration of the nostr.band API in the POWR app for retrieving profile statistics and other Nostr-related data. + +## nostr.band API Overview + +[Nostr.band](https://nostr.band) is a Nostr search engine and indexer that provides a comprehensive API for accessing aggregated statistics and data from the Nostr network. The API offers various endpoints for querying profile stats, events, and other Nostr-related information. + +### API Base URL + +``` +https://api.nostr.band +``` + +### Authentication + +The nostr.band API (as of March 2025) does not require authentication for basic profile statistics. It uses simple REST endpoints with path parameters. + +## Available Endpoints + +### Profile Statistics + +Retrieve follower and following counts for a specific pubkey: + +``` +GET /v0/stats/profile/{pubkey} +``` + +Where `{pubkey}` is the hex-format Nostr public key. + +#### Response Format + +```json +{ + "stats": { + "[pubkey]": { + "pubkey": "[pubkey]", + "followers_pubkey_count": 123, + "pub_following_pubkey_count": 456, + // Other stats may be available + } + } +} +``` + +## Implementation in POWR + +Our implementation consists of two main components: + +1. **NostrBandService**: A service class that handles API communication +2. **useProfileStats**: A React hook that provides the stats to components + +### NostrBandService + +Located at `lib/services/NostrBandService.ts`, this service: + +- Provides methods to interact with the nostr.band API +- Handles error cases and edge conditions +- Converts between npub and hex formats as needed + +```typescript +// Example usage: +import { nostrBandService } from '@/lib/services/NostrBandService'; + +// Fetch profile stats +const stats = await nostrBandService.fetchProfileStats('npub1...'); +console.log(stats.followersCount); // Number of followers +console.log(stats.followingCount); // Number of accounts being followed +``` + +### useProfileStats Hook + +Located at `lib/hooks/useProfileStats.ts`, this hook: + +- Wraps the NostrBandService in a React hook +- Provides loading/error states +- Supports automatic refresh intervals +- Falls back to current user's pubkey if none provided + +```typescript +// Example usage: +import { useProfileStats } from '@/lib/hooks/useProfileStats'; + +function MyComponent() { + const { + followersCount, + followingCount, + isLoading, + error + } = useProfileStats({ + pubkey: 'npub1...', // Optional: defaults to current user + refreshInterval: 60000 * 15 // Optional: refreshes every 15 minutes + }); + + if (isLoading) return ; + if (error) return ; + + return ( + + Followers: {followersCount} + Following: {followingCount} + + ); +} +``` + +## How to Use the nostr.band API in POWR + +### Step 1: Import the Required Dependencies + +```typescript +import { useProfileStats } from '@/lib/hooks/useProfileStats'; +// OR for direct API access: +import { nostrBandService } from '@/lib/services/NostrBandService'; +``` + +### Step 2: Use the Hook in Your Component + +```typescript +// With the hook (recommended for React components) +function ProfileComponent({ pubkey }) { + const { + followersCount, + followingCount, + isLoading, + refresh, // Function to manually refresh stats + lastRefreshed // Timestamp of last refresh + } = useProfileStats({ + pubkey, + refreshInterval: 300000 // 5 minutes in milliseconds (optional) + }); + + // Now use the stats in your UI +} +``` + +### Step 3: Handle Loading and Error States + +```typescript +if (isLoading) { + return Loading stats...; +} + +if (error) { + return Error loading stats: {error.message}; +} +``` + +### Step 4: Display the Data + +```typescript + + + {followingCount.toLocaleString()} + following + + + + {followersCount.toLocaleString()} + followers + + +``` + +## API Rate Limiting + +As of March 2025, the nostr.band API has the following rate limits: + +- 60 requests per minute per IP address +- 1000 requests per day per IP address + +To respect these limits: +- Implement reasonable refresh intervals (e.g., 15 minutes) +- Add caching mechanisms for frequently requested data +- Use error handling to gracefully degrade when rate limits are reached + +## Error Handling + +The service handles common error cases: + +1. Invalid npub format +2. Network errors +3. API errors (non-200 responses) +4. Missing data in response + +In all error cases, the service will: +- Log the error to the console +- Return a structured error object +- Set appropriate loading/error states + +## Future Enhancements + +Potential future enhancements for the nostr.band integration: + +1. Add support for additional endpoints (search, events, etc.) +2. Implement caching to reduce API calls +3. Add offline support with cached data +4. Expand UI to show additional statistics + +## References + +- [Nostr.band API Documentation](https://api.nostr.band/docs) (unofficial, as of 2025-03-28) +- [Nostr.band Website](https://nostr.band) +- [NostrBandService.ts](../../lib/services/NostrBandService.ts) - Service implementation +- [useProfileStats.ts](../../lib/hooks/useProfileStats.ts) - Hook implementation diff --git a/lib/hooks/useProfileStats.ts b/lib/hooks/useProfileStats.ts new file mode 100644 index 0000000..43e21d0 --- /dev/null +++ b/lib/hooks/useProfileStats.ts @@ -0,0 +1,79 @@ +// lib/hooks/useProfileStats.ts +import { useState, useEffect, useCallback } from 'react'; +import { nostrBandService, ProfileStats } from '@/lib/services/NostrBandService'; +import { useNDKCurrentUser } from '@/lib/hooks/useNDK'; + +interface UseProfileStatsOptions { + pubkey?: string; + refreshInterval?: number; // in milliseconds +} + +/** + * Hook to fetch profile statistics from nostr.band API + * Provides follower/following counts and other statistics + */ +export function useProfileStats(options: UseProfileStatsOptions = {}) { + const { currentUser } = useNDKCurrentUser(); + const { + pubkey: optionsPubkey, + refreshInterval = 0 // default to no auto-refresh + } = options; + + // Use provided pubkey or fall back to current user's pubkey + const pubkey = optionsPubkey || currentUser?.pubkey; + + const [stats, setStats] = useState({ + pubkey: pubkey || '', + followersCount: 0, + followingCount: 0, + isLoading: false, + error: null + }); + + const [lastRefreshed, setLastRefreshed] = useState(0); + + // Function to fetch profile stats + const fetchStats = useCallback(async () => { + if (!pubkey) return; + + setStats(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const profileStats = await nostrBandService.fetchProfileStats(pubkey); + setStats({ + ...profileStats, + isLoading: false + }); + setLastRefreshed(Date.now()); + } catch (error) { + console.error('Error in useProfileStats:', error); + setStats(prev => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error : new Error('Unknown error') + })); + } + }, [pubkey]); + + // Initial fetch + useEffect(() => { + if (pubkey) { + fetchStats(); + } + }, [pubkey, fetchStats]); + + // Set up refresh interval if specified + useEffect(() => { + if (refreshInterval > 0 && pubkey) { + const intervalId = setInterval(fetchStats, refreshInterval); + return () => clearInterval(intervalId); + } + }, [refreshInterval, pubkey, fetchStats]); + + // Return stats and helper functions + return { + ...stats, + refresh: fetchStats, + lastRefreshed + }; +} diff --git a/lib/services/NostrBandService.ts b/lib/services/NostrBandService.ts new file mode 100644 index 0000000..11c9962 --- /dev/null +++ b/lib/services/NostrBandService.ts @@ -0,0 +1,93 @@ +// lib/services/NostrBandService.ts +import { nip19 } from 'nostr-tools'; + +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'; + + /** + * Fetches profile statistics from nostr.band API + * @param pubkey Pubkey in hex format or npub format + * @returns Promise with profile stats + */ + async fetchProfileStats(pubkey: string): Promise { + try { + // 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) { + console.error('Error decoding npub:', error); + throw new Error('Invalid npub format'); + } + } + + // Build URL + const endpoint = `/v0/stats/profile/${hexPubkey}`; + const url = `${this.apiUrl}${endpoint}`; + + // Fetch data + const response = await fetch(url); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API error: ${response.status} - ${errorText}`); + } + + const data = await response.json() as NostrBandProfileStatsResponse; + + // Extract relevant stats + const profileStats = data.stats[hexPubkey]; + + if (!profileStats) { + throw new Error('Profile stats not found in response'); + } + + return { + pubkey: hexPubkey, + followersCount: profileStats.followers_pubkey_count ?? 0, + followingCount: profileStats.pub_following_pubkey_count ?? 0, + isLoading: false, + error: null + }; + } catch (error) { + console.error('Error fetching profile stats:', error); + return { + pubkey, + followersCount: 0, + followingCount: 0, + isLoading: false, + error: error instanceof Error ? error : new Error('Unknown error') + }; + } + } +} + +// Export a singleton instance +export const nostrBandService = new NostrBandService();