Merge pull request #29 from Permissionless-Software-Foundation/dh-nostr-following

feat(nostr): Added Nostr account following
This commit is contained in:
Chris Troutner
2025-07-11 06:53:52 -07:00
committed by GitHub
3 changed files with 205 additions and 18 deletions
@@ -6,12 +6,13 @@ import React, { useState, useEffect } from 'react'
import { Card } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import FollowBtn from './follow-btn.js'
import { RelayPool } from 'nostr'
function ContentCard (props) {
const [profile, setProfile] = useState([])
const [loaded, setLoaded] = useState(false)
const { creator } = props
const { creator, followList, refreshFollowList } = props
useEffect(() => {
const loadProfile = async () => {
@@ -103,13 +104,14 @@ function ContentCard (props) {
{/* Action Buttons */}
<div className='flex-shrink-0 d-flex flex-column gap-2'>
<button
className='btn btn-outline-danger btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem', minWidth: '100px' }}
>
<i className='bi bi-person-plus me-1' />
Follow
</button>
<FollowBtn
creator={creator}
appData={props.appData}
creatorProfile={profile}
followList={followList}
refreshFollowList={refreshFollowList}
/>
<button
className='btn btn-primary btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem', minWidth: '100px' }}
@@ -169,13 +171,13 @@ function ContentCard (props) {
{/* Action Buttons */}
<div className='d-flex gap-2'>
<button
className='btn btn-outline-danger btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem' }}
>
<i className='bi bi-person-plus me-1' />
Follow
</button>
<FollowBtn
creator={creator}
appData={props.appData}
creatorProfile={profile}
followList={followList}
refreshFollowList={refreshFollowList}
/>
<button
className='btn btn-primary btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem' }}
@@ -1,15 +1,51 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Spinner } from 'react-bootstrap'
import axios from 'axios'
// Local libraries
import config from '../../../../config'
import ContentCard from './content-card'
import { RelayPool } from 'nostr'
// Global variables and constants
const SERVER = config.dexServer
function ContentCreators (props) {
const [creators, setCreators] = useState([])
const [loaded, setLoaded] = useState(false)
const { appData } = props
const [followList, setFollowList] = useState([])
const getFollowList = useCallback(async () => {
const list = await new Promise((resolve, reject) => {
let list = []
const { nostrKeyPair } = appData.bchWalletState
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [3], authors: [nostrKeyPair.pubHex] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
/** This ensures to return an empty array if no records are found!
* Applies for new users that don't have a follow list
*/
resolve(list)
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
list = ev.tags
resolve(list)
})
})
console.log('Follow List', list)
setFollowList(list)
}, [appData])
useEffect(() => {
const loadCreators = async () => {
@@ -25,8 +61,9 @@ function ContentCreators (props) {
if (!loaded) {
loadCreators()
getFollowList()
}
}, [loaded])
}, [loaded, followList, getFollowList])
return (
<Container className='mt-4 mb-5'>
@@ -47,7 +84,13 @@ function ContentCreators (props) {
{loaded && (
<div>
{creators.map((creator, i) => (
<ContentCard key={`creator-key${i}`} creator={creator} appData={props.appData} />
<ContentCard
key={`creator-key${i}`}
creator={creator}
appData={props.appData}
followList={followList}
refreshFollowList={getFollowList}
/>
))}
</div>
)}
@@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react'
import { Spinner } from 'react-bootstrap'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils'
function FollowBtn (props) {
const [onFetch, setOnFetch] = useState(false)
const [isFollowing, setIsFollowing] = useState(false)
const { creator, appData, creatorProfile, followList, refreshFollowList } = props
useEffect(() => {
const isFollowing = followList.find(item => item[1] === creator.pubkey)
setIsFollowing(isFollowing)
}, [followList, creator.pubkey])
// Add creator to the follow list
const handleFollow = async () => {
try {
setOnFetch(true)
// Get current follow list
const currentList = followList
console.log('currentList', currentList)
// find if creator is in the list
const existing = currentList.find(item => item[1] === creator.pubkey)
if (!existing) {
const creatorName = creatorProfile.name || ''
// add creator to the list
currentList.push(['p', creator.pubkey, 'wss://nostr-relay.psfoundation.info', creatorName])
await submitFollowList(currentList)
await refreshFollowList()
} else {
console.log('Already Followed')
}
setOnFetch(false)
} catch (error) {
console.warn(error)
setOnFetch(false)
}
}
// Add creator to the follow list
const handleUnfollow = async () => {
try {
setOnFetch(true)
// Get current follow list
const currentList = followList
console.log('currentList', currentList)
// find if creator is in the list
const existing = currentList.find(item => item[1] === creator.pubkey)
if (existing) {
// remove creator from the list
currentList.splice(currentList.indexOf(existing), 1)
await submitFollowList(currentList)
await refreshFollowList()
}
setOnFetch(false)
} catch (error) {
console.warn(error)
setOnFetch(false)
}
}
const submitFollowList = async (newList) => {
try {
const { nostrKeyPair } = appData.bchWalletState
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
const psf = 'wss://nostr-relay.psfoundation.info'
// Generate a post.
const eventTemplate = {
kind: 3,
created_at: Math.floor(Date.now() / 1000),
tags: newList,
content: ''
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Connect to a relay.
const relay = await Relay.connect(psf)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Close the connection to the relay.
relay.close()
setTimeout(() => {
setOnFetch(false)
}, 1000)
} catch (error) {
console.warn(error)
setOnFetch(false)
}
}
return (
<>
{onFetch && (
<div className='d-flex justify-content-center'>
<Spinner animation='border' />
</div>
)}
{!onFetch && !isFollowing && (
<button
className='btn btn-outline-danger btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem', minWidth: '100px' }}
onClick={handleFollow}
>
<i className='bi bi-person-plus me-1' />
Follow
</button>
)}
{!onFetch && isFollowing && (
<button
className='btn btn-outline-danger btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem', minWidth: '100px' }}
onClick={handleUnfollow}
>
<i className='bi bi-person-plus me-1' />
Unfollow
</button>
)}
</>
)
}
export default FollowBtn