Sovran displays rich user profiles with social metadata from the Nostr network and Sovran’s reputation API.
Profiles are stored as kind 0 events with JSON content:
{
kind : 0 ,
content : JSON . stringify ({
name: "alice" ,
display_name: "Alice Smith" ,
about: "Bitcoin maximalist and Nostr enthusiast" ,
picture: "https://example.com/avatar.jpg" ,
banner: "https://example.com/banner.jpg" ,
website: "https://alice.com" ,
nip05: "alice@example.com" ,
lud16: "alice@getalby.com" ,
}),
created_at : 1234567890 ,
pubkey : "..." ,
}
Standard Fields
Field Description nameShort handle (e.g., “alice”) display_nameFull name (e.g., “Alice Smith”) aboutBio/description pictureAvatar image URL bannerBanner/header image URL websitePersonal website nip05NIP-05 identifier (verified DNS-based identity) lud16Lightning address (LNURL-pay) lud06Legacy LNURL-pay
Profile Screen
The main profile UI is in app/(user-flow)/profile.tsx:
// app/(user-flow)/profile.tsx
import { router , useLocalSearchParams } from 'expo-router' ;
import { npubToPubkey } from 'helper/nostrClient' ;
export default function UserProfileScreen () {
const { npub , pubkey : pubkeyParam } = useLocalSearchParams ();
const pubkey = useMemo (() => {
if ( pubkeyParam ) return pubkeyParam ;
if ( npub ) return npubToPubkey ( npub );
return '' ;
}, [ npub , pubkeyParam ]);
// Subscribe to profile metadata
const metadataFilters = useMemo (
() => ( pubkey ? [{ authors: [ pubkey ], kinds: [ Metadata ], limit: 1 }] : null ),
[ pubkey ]
);
const { events : metadataEvents , eose : metadataEose } = useSubscribe ({ filters: metadataFilters });
const userInfo = useMemo (() => {
if ( ! metadataEvents ?.[ 0 ]) return null ;
try {
return JSON . parse ( metadataEvents [ 0 ]. content );
} catch {
return null ;
}
}, [ metadataEvents ]);
return (
< UserFeed
pubkey = { pubkey }
authorName = { displayName }
authorPicture = {userInfo?. picture }
ListHeaderComponent = {
<View>
< BannerWithAvatar
bannerUrl = {userInfo?. banner }
pictureUrl = {userInfo?. picture }
pubkey = { pubkey }
displayName = { displayName }
nip05 = {userInfo?. nip05 }
/>
< ProfileStatsGrid
followingCount = { followingCount }
followerCount = { followerCount }
reputationScore = { reputationScore }
joinedDate = { joinedDate }
/>
<TopFollowers topFollowers = {profileData?.topFollowers || []} />
{userInfo?.about && <Card variant="info" message={userInfo.about} />}
</View>
}
/>
);
}
Banner & Avatar
The profile header displays:
Banner image : Full-width background (150px height)
Overlapping avatar : 90px circle with 4px border
Display name : From display_name or name
NIP-05 : Verified identifier with checkmark
Follow button : For non-own profiles
function BannerWithAvatar ({
bannerUrl ,
pictureUrl ,
pubkey ,
displayName ,
nip05 ,
isLoading ,
showFollowButton ,
isFollowing ,
onToggleFollow ,
}) {
const bannerGradientTheme = useMemo (
() => generateSeededGradient ( ` ${ pubkey } :person` , 'person' ),
[ pubkey ]
);
return (
< View >
{ /* Banner with gradient fallback */ }
< View style = {{ height : BANNER_HEIGHT }} >
< LinearGradient colors = {bannerGradientTheme. primaryColors } />
{ bannerUrl && < ExpoImage source = {{ uri : bannerUrl }} /> }
</ View >
{ /* Overlapping avatar */ }
< View style = {{ marginTop : - ( AVATAR_SIZE - AVATAR_OVERLAP ) }} >
< View style = {{ borderWidth : 4 , borderColor : background }} >
< Avatar picture = { pictureUrl } seed = { pubkey } size = { AVATAR_SIZE } />
</ View >
</ View >
{ /* Name & NIP-05 */ }
< VStack align = "center" >
< Text bold size = { 22 } > { displayName } </ Text >
{ nip05 && (
< HStack >
< Icon name = "mdi:check-decagram" size = { 16 } />
< Text size = { 14 } > { nip05 } </ Text >
</ HStack >
)}
{ showFollowButton && (
< TouchableOpacity onPress = { onToggleFollow } >
< Text >{ isFollowing ? 'Following' : 'Follow' }</ Text >
</ TouchableOpacity >
)}
</ VStack >
</ View >
);
}
Profile Stats Grid
Displays a 2x2 grid of key metrics:
function ProfileStatsGrid ({
followingCount ,
followerCount ,
reputationScore ,
joinedDate ,
isLoading ,
}) {
const stats = [
{
label: 'Following' ,
description: 'Users followed' ,
value: followingCount ?. toString () ?? '0' ,
},
{
label: 'Followers' ,
description: 'Total count' ,
value: followerCount ?. toString () ?? '0' ,
},
{
label: 'Reputation' ,
description: 'Network score' ,
value: reputationScore !== undefined ? ` ${ Math . round ( reputationScore ) } / 100` : 'N/A' ,
},
{
label: 'Joined' ,
description: 'Account created' ,
value: joinedDate || 'Unknown' ,
},
];
return (
< View style = {styles. statsGrid } >
< View style = {styles. statsRow } >
{ stats . slice (0, 2). map (( stat ) => (
< View key = {stat. label } style = {styles. statCard } >
< Text bold size = { 12 } > {stat.label.toUpperCase()} </ Text >
< Text bold size = { 20 } > {stat. value } </ Text >
< Text size = { 12 } > {stat. description } </ Text >
</ View >
))}
</ View >
< View style = {styles. statsRow } >
{ stats . slice (2, 4). map (( stat ) => (
< View key = {stat. label } style = {styles. statCard } >
< Text bold size = { 12 } > {stat.label.toUpperCase()} </ Text >
< Text bold size = { 16 } > {stat. value } </ Text >
< Text size = { 12 } > {stat. description } </ Text >
</ View >
))}
</ View >
</ View >
);
}
Reputation System
Sovran uses the Sovran API to fetch reputation scores:
// helper/apiClient.ts
export interface NostrProfileResponse {
pubkey : string ;
npub : string ;
rank : number ;
followers : number ;
follows : number ;
score : number ; // 0-100 reputation score
topFollowers : TopFollower [];
created_at : number ;
fromCache : boolean ;
mintUrl ?: string ; // If user operates a mint
}
export const fetchNostrProfile = ( pubkey : string ) =>
safeFetch < NostrProfileResponse >( ` ${ BASE_URL } /nostr/profile?pubkey= ${ pubkey } ` );
useNostrProfile Hook
// hooks/useNostrProfile.ts
import { fetchNostrProfile , type NostrProfileResponse } from '@/helper/apiClient' ;
export function useNostrProfile ( pubkey : string | null ) : UseNostrProfileResult {
const [ data , setData ] = useState < NostrProfileResponse | null >( null );
const [ isLoading , setIsLoading ] = useState ( false );
const [ error , setError ] = useState < Error | null >( null );
const fetchProfile = useCallback ( async () => {
if ( ! pubkey ) {
setData ( null );
setIsLoading ( false );
return ;
}
setIsLoading ( true );
setError ( null );
const result = await fetchNostrProfile ( pubkey );
if ( result . isOk ()) {
setData ( result . value );
} else {
setError ( result . error );
setData ( null );
}
setIsLoading ( false );
}, [ pubkey ]);
useEffect (() => {
fetchProfile ();
}, [ fetchProfile ]);
return { data , isLoading , error , refetch: fetchProfile };
}
Reputation Score Calculation
The API computes reputation based on:
Follower count : Number of followers
Follower quality : Weighted by follower reputation
Network position : Centrality in follow graph
Content engagement : Likes, replies, reposts
Account age : Older accounts score higher
The exact algorithm is proprietary but follows standard social graph analysis.
Top Followers
Displays the 6 most influential followers:
export interface TopFollower {
pubkey : string ;
npub : string ;
rank : number ;
name ?: string ;
displayName ?: string ;
picture ?: string ;
image ?: string ;
banner ?: string ;
about ?: string ;
nip05 ?: string ;
nip05Valid ?: boolean ;
website ?: string ;
lud16 ?: string ;
}
function TopFollowers ({ topFollowers , isLoading }) {
const followersWithProfiles = useMemo (
() => getFollowersWithProfiles ( topFollowers ). slice ( 0 , 6 ),
[ topFollowers ]
);
return (
< View >
< Text bold size = { 12 } > TOP FOLLOWERS </ Text >
< View style = {styles. topFollowersGrid } >
{ followersWithProfiles . map (( follower ) => (
< TouchableOpacity
key = {follower. pubkey }
onPress = {() => router.navigate( '/profile' , { npub : follower . npub })} >
< Avatar
picture = { getFollowerPicture ( follower )}
seed = {follower. pubkey }
size = { avatarSize }
/>
< Text size = { 11 } numberOfLines = { 1 } >
{ getFollowerDisplayName ( follower )}
</ Text >
</ TouchableOpacity >
))}
</ View >
</ View >
);
}
Helper Functions
// hooks/useNostrProfile.ts
export function getFollowersWithProfiles ( topFollowers : TopFollower []) : TopFollower [] {
return topFollowers . filter (( f ) => f . name || f . displayName || f . picture || f . image );
}
export function getFollowerDisplayName ( follower : TopFollower ) : string {
return follower . displayName || follower . name || follower . npub . slice ( 0 , 12 ) + '...' ;
}
export function getFollowerPicture ( follower : TopFollower ) : string | undefined {
return follower . picture || follower . image ;
}
Follow/Unfollow
Following is implemented via NIP-02 contact lists (kind 3):
const handleToggleFollow = useCallback ( async () => {
if ( ! pubkey || ! nostrKeys ?. pubkey || ! ndk ) {
engagementUpdateFailedPopup ( 'follow' );
return ;
}
if ( nostrKeys . pubkey === pubkey || followInFlight ) return ;
const shouldFollow = ! isFollowingProfile ;
setFollowOptimistic ( pubkey , shouldFollow , true );
const nextTags = buildUpdatedContactTags ( contactsTags , pubkey , shouldFollow );
const createdAt = Math . floor ( Date . now () / 1000 );
try {
const contactEvent = new NDKEvent ( ndk );
contactEvent . kind = Contacts ;
contactEvent . tags = nextTags ;
contactEvent . content = contactsContent ;
contactEvent . created_at = createdAt ;
await contactEvent . publish ();
setContactsFromRelay ({ tags: nextTags , content: contactsContent , createdAt });
clearFollowOptimistic ( pubkey );
} catch {
clearFollowOptimistic ( pubkey );
engagementUpdateFailedPopup ( 'follow' );
}
}, [ pubkey , nostrKeys ?. pubkey , ndk , isFollowingProfile , contactsTags , contactsContent ]);
function buildUpdatedContactTags (
existingTags : string [][],
targetPubkey : string ,
shouldFollow : boolean
) : string [][] {
// Remove existing entry for this pubkey
const nextTags = existingTags . filter (( tag ) => ! ( tag [ 0 ] === 'p' && tag [ 1 ] === targetPubkey ));
// Add back if following
if ( shouldFollow ) {
nextTags . push ([ 'p' , targetPubkey ]);
}
// Deduplicate
const seenP = new Set < string >();
const deduped : string [][] = [];
for ( const tag of nextTags ) {
if ( tag [ 0 ] !== 'p' ) {
deduped . push ( tag );
continue ;
}
const pk = tag [ 1 ];
if ( ! pk || seenP . has ( pk )) continue ;
seenP . add ( pk );
deduped . push ( tag );
}
return deduped ;
}
Optimistic Updates
Follow/unfollow uses optimistic UI updates:
// stores/nostrSocialStore.ts
interface NostrSocialState {
followingPubkeys : Record < string , boolean >;
optimisticFollowsByPubkey : Record < string , { value : boolean ; pending : boolean }>;
setFollowOptimistic : ( pubkey : string , value : boolean , pending : boolean ) => void ;
clearFollowOptimistic : ( pubkey : string ) => void ;
}
export const useNostrSocialStore = create < NostrSocialState >(( set ) => ({
followingPubkeys: {},
optimisticFollowsByPubkey: {},
setFollowOptimistic : ( pubkey , value , pending ) =>
set (( state ) => ({
optimisticFollowsByPubkey: {
... state . optimisticFollowsByPubkey ,
[pubkey]: { value , pending },
},
})),
clearFollowOptimistic : ( pubkey ) =>
set (( state ) => {
const next = { ... state . optimisticFollowsByPubkey };
delete next [ pubkey ];
return { optimisticFollowsByPubkey: next };
}),
}));
Profile Info Section
Displays copyable/clickable profile fields:
const profileInfoItems = useMemo (() => {
const items : {
key : string ;
prefix : React . ReactNode ;
title : string ;
suffixIcon : string ;
onPress : () => void ;
}[] = [
{
key: 'npub' ,
prefix: < CurrencyIcon colors ={[ iconColor ]} width ={ 20 } currency = "nostr" />,
title: truncateMiddle ( npub , 10 ),
suffixIcon: 'lets-icons:copy' ,
onPress : () => handleCopy ( npub , 'npub' ),
},
];
if ( userInfo ?. nip05 ) {
items . push ({
key: 'nip05' ,
prefix: < Icon name = "mdi:check-decagram" size ={ 20 } />,
title: userInfo . nip05 ,
suffixIcon: 'lets-icons:copy' ,
onPress : () => handleCopy ( userInfo . nip05 , 'nip05' ),
});
}
if ( userInfo ?. lud16 ) {
items . push ({
key: 'lud16' ,
prefix: < Icon name = "mdi:lightning-bolt" size ={ 20 } />,
title: userInfo . lud16 ,
suffixIcon: 'lets-icons:copy' ,
onPress : () => handleCopy ( userInfo . lud16 , 'lud16' ),
});
}
if ( userInfo ?. website ) {
items . push ({
key: 'website' ,
prefix: < Icon name = "mdi:web" size ={ 20 } />,
title: userInfo . website ,
suffixIcon: 'mdi:open-in-new' ,
onPress : () => handleOpenLink ( userInfo . website ),
});
}
return items ;
}, [ npub , userInfo , handleCopy , handleOpenLink ]);
return (
< ListGroup variant = "secondary" >
{ profileInfoItems . map (( item ) => (
< PressableFeedback key = {item. key } onPress = {item. onPress } >
< ListGroup . Item >
< ListGroup . ItemPrefix > {item. prefix } </ ListGroup . ItemPrefix >
< ListGroup . ItemContent >
< ListGroup . ItemTitle > {item. title } </ ListGroup . ItemTitle >
</ ListGroup . ItemContent >
< ListGroup . ItemSuffix >
< Icon name = {item. suffixIcon } size = { 20 } />
</ ListGroup . ItemSuffix >
</ ListGroup . Item >
</ PressableFeedback >
))}
</ ListGroup >
);
QR Code Sharing
Profiles can be shared via QR code:
// Header right button
< Link
href = {{
pathname : '/(user-flow)/share' ,
params : {
type : 'npub' ,
data : npub ,
... ( userInfo ?. lud16 && { lud16: userInfo . lud16 }),
},
}}
asChild >
< TouchableOpacity >
< Icon name = "mdi:qrcode" size = { 24 } />
</ TouchableOpacity >
</ Link >
The share screen displays:
QR code : Encoded npub or nprofile
Lightning address : If available
Profile card : Name, picture, NIP-05
Share button : Export to system share sheet
User Feed
Profiles include a feed of recent notes (kind 1 events):
import { UserFeed } from 'components/blocks/UserFeed' ;
< UserFeed
pubkey = { pubkey }
authorName = { displayName }
authorPicture = {userInfo?. picture }
isOwnProfile = { isOwnProfile }
onVideoPostsReady = { handleVideoPostsReady }
ListHeaderComponent = {<ProfileHeader />}
/>
The feed includes:
Text notes : Standard short-form posts
Replies : Threaded conversations
Reposts : Quoted or boosted notes
Reactions : Likes and custom emoji reactions
Video posts are used to populate the Stories feature.
Best Practices
Fallback for missing profile data
const displayName = userInfo ?. display_name || userInfo ?. name || truncateMiddle ( npub , 8 );
const picture = userInfo ?. picture || undefined ; // Triggers gradient
const banner = userInfo ?. banner || undefined ; // Triggers gradient
import { prefetchImages } from '@/helper/imageCache' ;
useEffect (() => {
if ( userInfo ?. picture ) prefetchImages ([ userInfo . picture ]);
if ( userInfo ?. banner ) prefetchImages ([ userInfo . banner ]);
}, [ userInfo ]);
Handle NIP-05 verification
const nip05Verified = userInfo ?. nip05 && ! userInfo ?. hasNip05Conflict ;
{ nip05Verified && (
< HStack >
< Icon name = "mdi:check-decagram" color = { successColor } />
< Text >{userInfo. nip05 } </ Text >
</ HStack >
)}
Safely parse profile JSON
const userInfo = useMemo (() => {
if ( ! metadataEvents ?.[ 0 ]) return null ;
try {
return JSON . parse ( metadataEvents [ 0 ]. content );
} catch {
console . warn ( 'Invalid profile metadata JSON' );
return null ;
}
}, [ metadataEvents ]);
Identity & Keys NIP-06 key derivation for profile signing
Contacts Contact lists and follow management
Direct Messages Send DMs from profile screen
Lightning Address Claim Lightning addresses for your profile