Compare commits

...
4 Commits
Author SHA1 Message Date
Chris Troutner ec4a4f8c41 Merge pull request #36 from Permissionless-Software-Foundation/dh-profile-fill
feat(nostr): Fill in Nostr Profile form with existing data
2025-07-20 06:23:18 -07:00
Daniel Gonzalez c14f60a36e feat(nostr): Fill in Nostr Profile form with existing data 2025-07-19 16:25:14 -04:00
Chris Troutner 062ad933d9 Merge pull request #35 from Permissionless-Software-Foundation/dh-follow-filter
feat(nostr): Filter Creators by Follower Count
2025-07-17 10:45:52 -07:00
Daniel Gonzalez a81dbb70e1 feat(nostr): Filter Creators by Follower Count 2025-07-17 11:41:57 -04:00
4 changed files with 249 additions and 155 deletions
@@ -7,43 +7,14 @@ import { Card } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon' import Jdenticon from '@chris.troutner/react-jdenticon'
import CopyOnClick from '../../bch-wallet/copy-on-click.js' import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import FollowBtn from './follow-btn.js' import FollowBtn from './follow-btn.js'
import { RelayPool } from 'nostr'
function ContentCard (props) { function ContentCard (props) {
const [profile, setProfile] = useState([])
const [loaded, setLoaded] = useState(false)
const { creator, followList, refreshFollowList } = props const { creator, followList, refreshFollowList } = props
const [profile, setProfile] = useState(null)
useEffect(() => { useEffect(() => {
const loadProfile = async () => { setProfile(creator?.profile || {})
const psf = 'wss://nostr-relay.psfoundation.info' }, [creator.profile])
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [creator.pubkey] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
console.log('profile', profile)
setProfile(profile)
} catch (error) {
console.log('Error parsing profile', error)
}
})
setLoaded(true)
}
if (!loaded && creator.npub) {
loadProfile()
}
}, [loaded, creator])
const goToProfile = () => { const goToProfile = () => {
const profileUrl = `${window.location.origin}/profile/${creator.npub}` const profileUrl = `${window.location.origin}/profile/${creator.npub}`
@@ -69,8 +40,8 @@ function ContentCard (props) {
{/* Creator Info */} {/* Creator Info */}
<div className='flex-grow-1'> <div className='flex-grow-1'>
<h5 className='mb-2 fw-bold cursor-pointer' onClick={goToProfile}>{profile.name}</h5> <h5 className='mb-2 fw-bold cursor-pointer' onClick={goToProfile}>{profile?.name}</h5>
<p className='text-muted medium mb-3'>{profile.about}</p> <p className='text-muted medium mb-3'>{profile?.about}</p>
{/* Nostr Public Key */} {/* Nostr Public Key */}
<div className='mb-2'> <div className='mb-2'>
@@ -126,7 +97,16 @@ function ContentCard (props) {
Message Message
</button> </button>
<small>Followers: {creator.followerCnt}</small> <div className='d-flex align-items-center justify-content-center gap-2 mt-2'>
<span
className='badge rounded-pill bg-white border text-dark px-3 py-2 shadow-sm d-flex align-items-center gap-2'
style={{ fontSize: '1rem', fontWeight: 600, cursor: 'auto' }}
>
<i className='bi bi-people-fill text-primary' style={{ fontSize: '1.2rem' }} />
<span style={{ fontWeight: 700 }}>{creator.followerCnt}</span>
<span className='text-muted' style={{ fontWeight: 400, fontSize: '0.9rem' }}>Followers</span>
</span>
</div>
</div> </div>
</div> </div>
@@ -139,8 +119,8 @@ function ContentCard (props) {
{/* Creator Info */} {/* Creator Info */}
<div className='w-100 mb-3'> <div className='w-100 mb-3'>
<h5 className='mb-2 fw-bold' onClick={goToProfile}>{profile.name}</h5> <h5 className='mb-2 fw-bold' onClick={goToProfile}>{profile?.name}</h5>
<p className='text-muted small mb-3'>{profile.about}</p> <p className='text-muted small mb-3'>{profile?.about}</p>
{/* Nostr Public Key */} {/* Nostr Public Key */}
<div className='mb-3'> <div className='mb-3'>
@@ -194,6 +174,13 @@ function ContentCard (props) {
Message Message
</button> </button>
</div> </div>
<div className='d-flex align-items-center justify-content-center gap-2 mt-2'>
<span className='badge rounded-pill bg-white border text-dark px-3 py-2 shadow-sm d-flex align-items-center gap-2' style={{ fontSize: '1rem', fontWeight: 600 }}>
<i className='bi bi-people-fill text-primary' style={{ fontSize: '1.2rem' }} />
<span style={{ fontWeight: 700 }}>{creator.followerCnt}</span>
<span className='text-muted' style={{ fontWeight: 400, fontSize: '0.9rem' }}>Followers</span>
</span>
</div>
</div> </div>
</Card.Body> </Card.Body>
</Card> </Card>
@@ -1,5 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { Container, Spinner, Form } from 'react-bootstrap' import { Container, Spinner, Dropdown } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faFilter } from '@fortawesome/free-solid-svg-icons'
import axios from 'axios' import axios from 'axios'
// Local libraries // Local libraries
@@ -12,6 +14,7 @@ const SERVER = config.dexServer
function ContentCreators (props) { function ContentCreators (props) {
const [creators, setCreators] = useState([]) const [creators, setCreators] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [selectedFilter, setSelectedFilter] = useState('Most Followers')
const { appData } = props const { appData } = props
const [followList, setFollowList] = useState([]) const [followList, setFollowList] = useState([])
@@ -47,13 +50,57 @@ function ContentCreators (props) {
setFollowList(list) setFollowList(list)
}, [appData]) }, [appData])
const loadProfile = useCallback(async (pubKey) => {
return new Promise((resolve) => {
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubKey] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
resolve(false)
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
// console.log('profile', profile)
resolve(profile)
} catch (error) {
resolve(false)
}
})
})
}, [])
useEffect(() => { useEffect(() => {
const loadCreators = async () => { const loadCreators = async () => {
try { try {
const creators = await axios.get(`${SERVER}/sm/list/all/0`) const creatorsRes = await axios.get(`${SERVER}/sm/list/all/0`)
console.log(creators.data) const creators = creatorsRes.data
setCreators(creators.data) // console.log('creators', creators)
setCreators(creators)
setLoaded(true) setLoaded(true)
for (let i = 0; i < creators.length; i++) {
try {
const creator = creators[i]
const profile = await loadProfile(creator.pubkey)
// the folowing lines should re-render the ContentCard
setCreators(prevCreators => {
const updatedCreators = [...prevCreators]
updatedCreators[i] = { ...updatedCreators[i], profile }
return updatedCreators
})
} catch (error) {
console.warn(error)
}
}
} catch (error) { } catch (error) {
setLoaded(true) setLoaded(true)
} }
@@ -63,7 +110,21 @@ function ContentCreators (props) {
loadCreators() loadCreators()
getFollowList() getFollowList()
} }
}, [loaded, followList, getFollowList]) }, [loaded, followList, getFollowList, loadProfile])
const filteredCreators = useCallback(() => {
if (!creators || creators.length === 0) {
return []
}
let filtered = [...creators]
if (selectedFilter === 'Most Followers') {
filtered = filtered.sort((a, b) => (b.followerCnt || 0) - (a.followerCnt || 0))
}
return filtered
}, [creators, selectedFilter])
return ( return (
<Container className='mt-4 mb-5'> <Container className='mt-4 mb-5'>
@@ -74,13 +135,25 @@ function ContentCreators (props) {
</p> </p>
</div> </div>
<div className='mb-4'> <div className='mb-4 d-flex justify-content-end'>
<h3>Filters</h3> <Dropdown>
<Form.Select> <Dropdown.Toggle variant='outline-secondary' className='d-flex align-items-center gap-2'>
<option>Most Followers</option> <FontAwesomeIcon icon={faFilter} />
<option>Most Tokens</option> <span>{selectedFilter}</span>
<option>Most Likes</option> </Dropdown.Toggle>
</Form.Select>
<Dropdown.Menu>
<Dropdown.Item onClick={() => setSelectedFilter('Most Followers')}>
Most Followers
</Dropdown.Item>
<Dropdown.Item onClick={() => setSelectedFilter('Most Tokens')}>
Most Tokens
</Dropdown.Item>
<Dropdown.Item onClick={() => setSelectedFilter('Most Likes')}>
Most Likes
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div> </div>
{!loaded && ( {!loaded && (
@@ -92,7 +165,7 @@ function ContentCreators (props) {
{loaded && ( {loaded && (
<div> <div>
{creators.map((creator, i) => ( {filteredCreators().map((creator, i) => (
<ContentCard <ContentCard
key={`creator-key${i}`} key={`creator-key${i}`}
creator={creator} creator={creator}
@@ -104,7 +177,7 @@ function ContentCreators (props) {
</div> </div>
)} )}
{loaded && creators.length === 0 && ( {loaded && filteredCreators().length === 0 && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'> <div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<i className='bi bi-people-fill fs-1 mb-3 d-block opacity-50' /> <i className='bi bi-people-fill fs-1 mb-3 d-block opacity-50' />
<div>No content creators found</div> <div>No content creators found</div>
@@ -3,17 +3,19 @@ Component for posting nostr information on kind 0.
*/ */
// Global npm libraries // Global npm libraries
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
import { Container, Form, Button, Spinner } from 'react-bootstrap' import { Container, Form, Button, Spinner } from 'react-bootstrap'
import Accordion from 'react-bootstrap/Accordion' import Accordion from 'react-bootstrap/Accordion'
import { finalizeEvent } from 'nostr-tools/pure' import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay' import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import { RelayPool } from 'nostr'
function ProfilePost (props) { function ProfilePost (props) {
const { bchWalletState } = props.appData
const [accordionKey, setAccordionKey] = useState(null) const [accordionKey, setAccordionKey] = useState(null)
const [onFetch, setOnFetch] = useState(false) const [onFetch, setOnFetch] = useState(false)
const { bchWalletState } = props.appData const [formLoaded, setFormLoaded] = useState(false)
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
about: '', about: '',
@@ -27,6 +29,43 @@ function ProfilePost (props) {
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const [successMsg, setSuccessMsg] = useState('') const [successMsg, setSuccessMsg] = useState('')
useEffect(() => {
// Get Last post from
const getLastPost = () => {
const { nostrKeyPair } = bchWalletState
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [0], authors: [nostrKeyPair.pubHex] })
setFormLoaded(true)
})
pool.on('eose', relay => {
console.log('Closing Relay')
setFormLoaded(true)
relay.close()
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
// setPosts(currentPosts => [...currentPosts, ev])
try {
const data = JSON.parse(ev.content)
console.log('data', data)
setFormData(data)
} catch (error) {
}
})
}
if (!formLoaded) {
getLastPost()
}
}, [bchWalletState, formLoaded])
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value } = e.target const { name, value } = e.target
setFormData({ setFormData({
@@ -78,7 +117,6 @@ function ProfilePost (props) {
// Close the connection to the relay. // Close the connection to the relay.
relay.close() relay.close()
resetForm()
setSuccessMsg('Post successfully published!') setSuccessMsg('Post successfully published!')
setOnFetch(false) setOnFetch(false)
} catch (error) { } catch (error) {
@@ -92,18 +130,6 @@ function ProfilePost (props) {
setAccordionKey(key) setAccordionKey(key)
} }
const resetForm = () => {
setFormData({
name: '',
about: '',
picture: '',
display_name: '',
website: '',
banner: '',
bot: false
})
}
return ( return (
<> <>
<Container className='mt-4'> <Container className='mt-4'>
@@ -121,103 +147,111 @@ function ProfilePost (props) {
{successMsg} {successMsg}
</div> </div>
)} )}
<Form onSubmit={handleSubmit}> {formLoaded && (
<Form.Group className='mb-3'> <Form onSubmit={handleSubmit}>
<Form.Label>Name</Form.Label> <Form.Group className='mb-3'>
<Form.Control <Form.Label>Name</Form.Label>
type='text' <Form.Control
placeholder='Enter your name' type='text'
name='name' placeholder='Enter your name'
value={formData.name} name='name'
onChange={handleInputChange} value={formData.name}
required onChange={handleInputChange}
/> required
</Form.Group> />
</Form.Group>
<Form.Group className='mb-3'> <Form.Group className='mb-3'>
<Form.Label>About</Form.Label> <Form.Label>About</Form.Label>
<Form.Control <Form.Control
as='textarea' as='textarea'
rows={3} rows={3}
placeholder='Tell us about yourself' placeholder='Tell us about yourself'
name='about' name='about'
value={formData.about} value={formData.about}
onChange={handleInputChange} onChange={handleInputChange}
required required
/> />
</Form.Group> </Form.Group>
<Form.Group className='mb-3'> <Form.Group className='mb-3'>
<Form.Label>Profile Picture URL</Form.Label> <Form.Label>Profile Picture URL</Form.Label>
<Form.Control <Form.Control
type='url' type='url'
placeholder='Enter URL to your profile picture' placeholder='Enter URL to your profile picture'
name='picture' name='picture'
value={formData.picture} value={formData.picture}
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Group className='mb-3'> <Form.Group className='mb-3'>
<Form.Label>Display Name</Form.Label> <Form.Label>Display Name</Form.Label>
<Form.Control <Form.Control
type='text' type='text'
placeholder='Enter your display name' placeholder='Enter your display name'
name='display_name' name='display_name'
value={formData.display_name} value={formData.display_name}
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Group className='mb-3'> <Form.Group className='mb-3'>
<Form.Label>Website</Form.Label> <Form.Label>Website</Form.Label>
<Form.Control <Form.Control
type='url' type='url'
placeholder='Enter your website URL' placeholder='Enter your website URL'
name='website' name='website'
value={formData.website} value={formData.website}
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Group className='mb-3'> <Form.Group className='mb-3'>
<Form.Label>Banner URL</Form.Label> <Form.Label>Banner URL</Form.Label>
<Form.Control <Form.Control
type='url' type='url'
placeholder='Enter URL to your banner picture (1024x768 pixels)' placeholder='Enter URL to your banner picture (1024x768 pixels)'
name='banner' name='banner'
value={formData.banner} value={formData.banner}
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Group className='mb-3'> <Form.Group className='mb-3'>
<Form.Check <Form.Check
type='checkbox' type='checkbox'
label='This account is for a bot' label='This account is for a bot'
name='bot' name='bot'
checked={formData.bot} checked={formData.bot}
onChange={(e) => { onChange={(e) => {
setFormData({ setFormData({
...formData, ...formData,
bot: e.target.checked bot: e.target.checked
}) })
setSuccessMsg('') setSuccessMsg('')
setErrorMsg('') setErrorMsg('')
}} }}
/> />
</Form.Group> </Form.Group>
<div className='d-flex justify-content-center'> <div className='d-flex justify-content-center'>
{!onFetch && ( {!onFetch && (
<Button variant='primary' type='submit' disabled={onFetch}> <Button variant='primary' type='submit' disabled={onFetch}>
Post Post
</Button> </Button>
)} )}
{onFetch && <Spinner animation='border' variant='primary' />} {onFetch && <Spinner animation='border' variant='primary' />}
</div>
</Form>
)}
{!formLoaded && (
<div className='text-center'>
<Spinner animation='border' variant='primary' />
</div> </div>
)}
</Form>
</Accordion.Body> </Accordion.Body>
</Accordion.Item> </Accordion.Item>
</Accordion> </Accordion>
+1 -1
View File
@@ -15,7 +15,7 @@ const config = {
radicleUrl: 'https://app.radicle.network/seeds/maple.radicle.garden/rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy/remotes/hyyycncbn9qzqmobnhjq9rry6t4mbjiadzjoyhaknzxjcz3cxkpfpc', radicleUrl: 'https://app.radicle.network/seeds/maple.radicle.garden/rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy/remotes/hyyycncbn9qzqmobnhjq9rry6t4mbjiadzjoyhaknzxjcz3cxkpfpc',
dexServer: 'https://dex-api.fullstack.cash', dexServer: 'https://dex-api.fullstack.cash',
//dexServer: 'http://localhost:5700', // dexServer: 'http://localhost:5700',
nostrTopic: 'bch-dex-test-topic-02', nostrTopic: 'bch-dex-test-topic-02',
nostrRelay: 'wss://nostr-relay.psfoundation.info' nostrRelay: 'wss://nostr-relay.psfoundation.info'