nostr.band integration for follow/following count

This commit is contained in:
DocNR 2025-03-28 10:18:44 -07:00
parent 08bb9884bc
commit 3f2ababe4f
6 changed files with 658 additions and 40 deletions

View File

@ -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/), 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). 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 # Changelog - March 26, 2025
## Fixed ## Fixed

View File

@ -1,6 +1,6 @@
// app/(tabs)/profile/overview.tsx // app/(tabs)/profile/overview.tsx
import React, { useState, useCallback } from 'react'; 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 { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useNDKCurrentUser } from '@/lib/hooks/useNDK'; import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
@ -10,6 +10,7 @@ import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost'; import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
import EmptyFeed from '@/components/social/EmptyFeed'; import EmptyFeed from '@/components/social/EmptyFeed';
import { useSocialFeed } from '@/lib/hooks/useSocialFeed'; import { useSocialFeed } from '@/lib/hooks/useSocialFeed';
import { useProfileStats } from '@/lib/hooks/useProfileStats';
import { import {
AnyFeedEntry, AnyFeedEntry,
WorkoutFeedEntry, WorkoutFeedEntry,
@ -24,6 +25,7 @@ import { QrCode, Mail, Copy } from 'lucide-react-native';
import { useTheme } from '@react-navigation/native'; import { useTheme } from '@react-navigation/native';
import type { CustomTheme } from '@/lib/theme'; import type { CustomTheme } from '@/lib/theme';
import { Alert } from 'react-native'; import { Alert } from 'react-native';
import { nip19 } from 'nostr-tools';
// Define the conversion function for feed items // Define the conversion function for feed items
function convertToLegacyFeedItem(entry: AnyFeedEntry) { function convertToLegacyFeedItem(entry: AnyFeedEntry) {
@ -139,6 +141,50 @@ export default function OverviewScreen() {
const pubkey = currentUser?.pubkey; 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 (
<View className="flex-row mb-2">
<TouchableOpacity className="mr-4">
<Text>
<Text className="font-bold">{isLoading ? '...' : followingCount.toLocaleString()}</Text>
<Text className="text-muted-foreground"> following</Text>
</Text>
</TouchableOpacity>
<TouchableOpacity>
<Text>
<Text className="font-bold">{isLoading ? '...' : followersCount.toLocaleString()}</Text>
<Text className="text-muted-foreground"> followers</Text>
</Text>
</TouchableOpacity>
</View>
);
});
// 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 // Handle refresh
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
setIsRefreshing(true); setIsRefreshing(true);
@ -159,14 +205,18 @@ export default function OverviewScreen() {
console.log(`Selected ${entry.type}:`, entry); console.log(`Selected ${entry.type}:`, entry);
}, []); }, []);
// Copy pubkey to clipboard // Copy npub to clipboard
const copyPubkey = useCallback(() => { const copyPubkey = useCallback(() => {
if (pubkey) { if (pubkey) {
// Simple alert instead of clipboard functionality try {
Alert.alert('Pubkey', pubkey, [ const npub = nip19.npubEncode(pubkey);
{ text: 'OK' } Clipboard.setString(npub);
]); Alert.alert('Copied', 'Public key copied to clipboard in npub format');
console.log('Pubkey shown to user'); console.log('npub copied to clipboard:', npub);
} catch (error) {
console.error('Error copying npub:', error);
Alert.alert('Error', 'Failed to copy public key');
}
} }
}, [pubkey]); }, [pubkey]);
@ -238,22 +288,8 @@ export default function OverviewScreen() {
style={{ width: 90, height: 90 }} style={{ width: 90, height: 90 }}
/> />
{/* Action buttons - positioned to the right */} {/* Edit Profile button - positioned to the right */}
<View className="flex-row ml-auto mb-2"> <View className="ml-auto mb-2">
<TouchableOpacity
className="w-10 h-10 items-center justify-center rounded-md bg-muted mr-2"
onPress={showQRCode}
>
<QrCode size={18} color={theme.colors.text} />
</TouchableOpacity>
<TouchableOpacity
className="w-10 h-10 items-center justify-center rounded-md bg-muted mr-2"
onPress={copyPubkey}
>
<Copy size={18} color={theme.colors.text} />
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
className="px-4 h-10 items-center justify-center rounded-md bg-muted" className="px-4 h-10 items-center justify-center rounded-md bg-muted"
onPress={() => router.push('/profile/settings')} onPress={() => router.push('/profile/settings')}
@ -266,24 +302,35 @@ export default function OverviewScreen() {
{/* Profile info */} {/* Profile info */}
<View> <View>
<Text className="text-xl font-bold">{displayName}</Text> <Text className="text-xl font-bold">{displayName}</Text>
<Text className="text-muted-foreground mb-2">{username}</Text> <Text className="text-muted-foreground">{username}</Text>
{/* Display npub below username with sharing options */}
{npubFormat && (
<View className="flex-row items-center mt-1 mb-2">
<Text className="text-xs text-muted-foreground font-mono">
{shortenedNpub}
</Text>
<TouchableOpacity
className="ml-2 p-1"
onPress={copyPubkey}
accessibilityLabel="Copy public key"
accessibilityHint="Copies your Nostr public key to clipboard"
>
<Copy size={12} color={theme.colors.text} />
</TouchableOpacity>
<TouchableOpacity
className="ml-2 p-1"
onPress={showQRCode}
accessibilityLabel="Show QR Code"
accessibilityHint="Shows a QR code with your Nostr public key"
>
<QrCode size={12} color={theme.colors.text} />
</TouchableOpacity>
</View>
)}
{/* Follower stats */} {/* Follower stats */}
<View className="flex-row mb-2"> <ProfileFollowerStats pubkey={pubkey} />
<TouchableOpacity className="mr-4">
<Text>
<Text className="font-bold">2,003</Text>
<Text className="text-muted-foreground"> following</Text>
</Text>
</TouchableOpacity>
<TouchableOpacity>
<Text>
<Text className="font-bold">4,764</Text>
<Text className="text-muted-foreground"> followers</Text>
</Text>
</TouchableOpacity>
</View>
{/* About text */} {/* About text */}
{aboutText && ( {aboutText && (
@ -295,7 +342,7 @@ export default function OverviewScreen() {
<View className="h-px bg-border w-full mt-2" /> <View className="h-px bg-border w-full mt-2" />
</View> </View>
</View> </View>
), [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) { if (loading && entries.length === 0) {
return ( return (

View File

@ -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 (
<View className="flex-row mb-2">
<TouchableOpacity className="mr-4">
<Text>
<Text className="font-bold">{isLoading ? '...' : followingCount.toLocaleString()}</Text>
<Text className="text-muted-foreground"> following</Text>
</Text>
</TouchableOpacity>
<TouchableOpacity>
<Text>
<Text className="font-bold">{isLoading ? '...' : followersCount.toLocaleString()}</Text>
<Text className="text-muted-foreground"> followers</Text>
</Text>
</TouchableOpacity>
</View>
);
});
```
### Usage
The component is used in the profile screen as follows:
```tsx
// Inside profile screen component
<ProfileFollowerStats pubkey={currentUser?.pubkey} />
```
## 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

View File

@ -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 <LoadingIndicator />;
if (error) return <ErrorMessage error={error} />;
return (
<View>
<Text>Followers: {followersCount}</Text>
<Text>Following: {followingCount}</Text>
</View>
);
}
```
## 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 <Text>Loading stats...</Text>;
}
if (error) {
return <Text>Error loading stats: {error.message}</Text>;
}
```
### Step 4: Display the Data
```typescript
<View className="flex-row mb-2">
<Text>
<Text className="font-bold">{followingCount.toLocaleString()}</Text>
<Text className="text-muted-foreground"> following</Text>
</Text>
<Text className="ml-4">
<Text className="font-bold">{followersCount.toLocaleString()}</Text>
<Text className="text-muted-foreground"> followers</Text>
</Text>
</View>
```
## 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

View File

@ -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<ProfileStats>({
pubkey: pubkey || '',
followersCount: 0,
followingCount: 0,
isLoading: false,
error: null
});
const [lastRefreshed, setLastRefreshed] = useState<number>(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
};
}

View File

@ -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<ProfileStats> {
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();