Compare commits

...
6 Commits
Author SHA1 Message Date
Chris Troutner 98603d3fc5 Merge pull request #30 from Permissionless-Software-Foundation/dh-profile-url
feat(nostr): Added npub to Profile URL to lookup any Nostr Profile
2025-07-14 17:47:46 -07:00
Daniel Gonzalez 0f16c3e0fb feat(nostr): Added npub to Profile URL to lookup any Nostr Profile 2025-07-14 13:25:27 -04:00
Chris Troutner 43b35a3553 Merge pull request #29 from Permissionless-Software-Foundation/dh-nostr-following
feat(nostr): Added Nostr account following
2025-07-11 06:53:52 -07:00
Daniel Gonzalez 862377b929 feat(nostr): Added Nostr account following 2025-07-10 19:02:01 -04:00
Chris Troutner 02e800ff6a Merge pull request #28 from Permissionless-Software-Foundation/dh-profile-refactor
feat(nostr): Expanded profile properties
2025-07-08 16:51:23 -07:00
Daniel Gonzalez 7e1b75f965 feat(nostr): Expanded profile properties 2025-07-08 18:46:03 -04:00
9 changed files with 317 additions and 40 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ function AppBody (props) {
<Route path='/sign' element={<SignMessage appData={appData} />} /> <Route path='/sign' element={<SignMessage appData={appData} />} />
<Route path='/configuration' element={<ServerSelectView appData={appData} />} /> <Route path='/configuration' element={<ServerSelectView appData={appData} />} />
<Route path='/nostr-post' element={<NostrPost appData={appData} />} /> <Route path='/nostr-post' element={<NostrPost appData={appData} />} />
<Route path='/nostr-read' element={<NostrRead appData={appData} />} /> <Route path='/nostr-read/:npub' element={<NostrRead appData={appData} />} />
<Route path='/global-feed' element={<GlobalFeed appData={appData} />} /> <Route path='/global-feed' element={<GlobalFeed appData={appData} />} />
<Route path='/content-creators' element={<ContentCreators appData={appData} />} /> <Route path='/content-creators' element={<ContentCreators appData={appData} />} />
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} /> <Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
@@ -6,12 +6,13 @@ import React, { useState, useEffect } from 'react'
import { Card } from 'react-bootstrap' 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 { RelayPool } from 'nostr' import { RelayPool } from 'nostr'
function ContentCard (props) { function ContentCard (props) {
const [profile, setProfile] = useState([]) const [profile, setProfile] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const { creator } = props const { creator, followList, refreshFollowList } = props
useEffect(() => { useEffect(() => {
const loadProfile = async () => { const loadProfile = async () => {
@@ -44,26 +45,31 @@ function ContentCard (props) {
} }
}, [loaded, creator]) }, [loaded, creator])
const goToProfile = () => {
const profileUrl = `${window.location.origin}/nostr-read/${creator.npub}`
window.open(profileUrl, '_blank')
}
return ( return (
<> <>
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'> <Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-4'> <Card.Body className='p-4'>
{/* Desktop Layout - Horizontal */} {/* Desktop Layout - Horizontal */}
<div className='d-flex justify-content-end mb-2'> <div className='d-flex justify-content-end mb-2' onClick={goToProfile}>
<small className='text-muted'> <small className='text-muted'>
{new Date(creator.createdAt).toLocaleString()} {new Date(creator.createdAt).toLocaleString()}
</small> </small>
</div> </div>
<div className='d-none d-md-flex align-items-center gap-4'> <div className='d-none d-md-flex align-items-center gap-4 cursor-pointer'>
{/* Profile Picture */} {/* Profile Picture */}
<div className='flex-shrink-0'> <div className='flex-shrink-0' onClick={goToProfile}>
<Jdenticon size='140' value={creator.npub} /> <Jdenticon size='140' value={creator.npub} />
</div> </div>
{/* Creator Info */} {/* Creator Info */}
<div className='flex-grow-1'> <div className='flex-grow-1'>
<h5 className='mb-2 fw-bold'>{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 */}
@@ -103,13 +109,14 @@ function ContentCard (props) {
{/* Action Buttons */} {/* Action Buttons */}
<div className='flex-shrink-0 d-flex flex-column gap-2'> <div className='flex-shrink-0 d-flex flex-column gap-2'>
<button <FollowBtn
className='btn btn-outline-danger btn-sm rounded-pill px-3' creator={creator}
style={{ fontSize: '0.875rem', minWidth: '100px' }} appData={props.appData}
> creatorProfile={profile}
<i className='bi bi-person-plus me-1' /> followList={followList}
Follow refreshFollowList={refreshFollowList}
</button>
/>
<button <button
className='btn btn-primary btn-sm rounded-pill px-3' className='btn btn-primary btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem', minWidth: '100px' }} style={{ fontSize: '0.875rem', minWidth: '100px' }}
@@ -124,12 +131,12 @@ function ContentCard (props) {
<div className='d-flex d-md-none flex-column align-items-center text-center'> <div className='d-flex d-md-none flex-column align-items-center text-center'>
{/* Profile Picture */} {/* Profile Picture */}
<div className='mb-3'> <div className='mb-3'>
<Jdenticon size='100' value={creator.npub} /> <Jdenticon size='100' value={creator.npub} onClick={goToProfile} />
</div> </div>
{/* Creator Info */} {/* Creator Info */}
<div className='w-100 mb-3'> <div className='w-100 mb-3'>
<h5 className='mb-2 fw-bold'>{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 */}
@@ -169,13 +176,13 @@ function ContentCard (props) {
{/* Action Buttons */} {/* Action Buttons */}
<div className='d-flex gap-2'> <div className='d-flex gap-2'>
<button <FollowBtn
className='btn btn-outline-danger btn-sm rounded-pill px-3' creator={creator}
style={{ fontSize: '0.875rem' }} appData={props.appData}
> creatorProfile={profile}
<i className='bi bi-person-plus me-1' /> followList={followList}
Follow refreshFollowList={refreshFollowList}
</button> />
<button <button
className='btn btn-primary btn-sm rounded-pill px-3' className='btn btn-primary btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem' }} 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 { Container, Spinner } from 'react-bootstrap'
import axios from 'axios' import axios from 'axios'
// Local libraries // Local libraries
import config from '../../../../config' import config from '../../../../config'
import ContentCard from './content-card' import ContentCard from './content-card'
import { RelayPool } from 'nostr'
// Global variables and constants // Global variables and constants
const SERVER = config.dexServer 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 { 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(() => { useEffect(() => {
const loadCreators = async () => { const loadCreators = async () => {
@@ -25,8 +61,9 @@ function ContentCreators (props) {
if (!loaded) { if (!loaded) {
loadCreators() loadCreators()
getFollowList()
} }
}, [loaded]) }, [loaded, followList, getFollowList])
return ( return (
<Container className='mt-4 mb-5'> <Container className='mt-4 mb-5'>
@@ -47,7 +84,13 @@ function ContentCreators (props) {
{loaded && ( {loaded && (
<div> <div>
{creators.map((creator, i) => ( {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> </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
@@ -16,7 +16,12 @@ function ProfilePost (props) {
const { bchWalletState } = props.appData const { bchWalletState } = props.appData
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
about: '' about: '',
picture: '',
display_name: '',
website: '',
banner: '',
bot: false
}) })
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
@@ -90,7 +95,12 @@ function ProfilePost (props) {
const resetForm = () => { const resetForm = () => {
setFormData({ setFormData({
name: '', name: '',
about: '' about: '',
picture: '',
display_name: '',
website: '',
banner: '',
bot: false
}) })
} }
@@ -137,6 +147,67 @@ function ProfilePost (props) {
/> />
</Form.Group> </Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Profile Picture URL</Form.Label>
<Form.Control
type='url'
placeholder='Enter URL to your profile picture'
name='picture'
value={formData.picture}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Display Name</Form.Label>
<Form.Control
type='text'
placeholder='Enter your display name'
name='display_name'
value={formData.display_name}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Website</Form.Label>
<Form.Control
type='url'
placeholder='Enter your website URL'
name='website'
value={formData.website}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Banner URL</Form.Label>
<Form.Control
type='url'
placeholder='Enter URL to your banner picture (1024x768 pixels)'
name='banner'
value={formData.banner}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Check
type='checkbox'
label='This account is for a bot'
name='bot'
checked={formData.bot}
onChange={(e) => {
setFormData({
...formData,
bot: e.target.checked
})
setSuccessMsg('')
setErrorMsg('')
}}
/>
</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}>
@@ -7,14 +7,17 @@ import React, { useState } from 'react'
import { Container } from 'react-bootstrap' import { Container } from 'react-bootstrap'
import ProfileRead from './profile-read.js' import ProfileRead from './profile-read.js'
import PublicRead from './public-read.js' import PublicRead from './public-read.js'
import { useParams } from 'react-router-dom'
function NostrRead (props) { function NostrRead (props) {
const [profile, setProfile] = useState(false) const [profile, setProfile] = useState(false)
const { npub } = useParams()
console.log('npub', npub)
return ( return (
<> <>
<Container> <Container>
<ProfileRead {...props} setProfile={setProfile} /> <ProfileRead {...props} setProfile={setProfile} npub={npub} />
<PublicRead {...props} profile={profile} /> <PublicRead {...props} profile={profile} npub={npub} />
</Container> </Container>
</> </>
) )
@@ -9,21 +9,27 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons' import { faUser } from '@fortawesome/free-solid-svg-icons'
import NostrFormat from '../nostr-format' import NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr' import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
function ProfileRead (props) { function ProfileRead (props) {
const { npub } = props
const { bchWalletState } = props.appData const { bchWalletState } = props.appData
console.log('npub prop', npub)
const { setProfile } = props const { setProfile } = props
const [post, setPost] = useState({}) const [post, setPost] = useState({})
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
useEffect(() => { useEffect(() => {
const start = () => { const start = () => {
const { nostrKeyPair } = bchWalletState const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
console.log('pubhex', pubHex)
const psf = 'wss://nostr-relay.psfoundation.info' const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf]) const pool = RelayPool([psf])
pool.on('open', relay => { pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [nostrKeyPair.pubHex] }) relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubHex] })
setLoaded(true)
}) })
pool.on('eose', relay => { pool.on('eose', relay => {
@@ -34,6 +40,7 @@ function ProfileRead (props) {
pool.on('event', (relay, subId, ev) => { pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev) console.log('Received event:', ev)
const profile = JSON.parse(ev.content) const profile = JSON.parse(ev.content)
console.log('Profile:', profile)
setPost(profile) setPost(profile)
setProfile(profile) setProfile(profile)
}) })
@@ -41,10 +48,10 @@ function ProfileRead (props) {
setLoaded(true) setLoaded(true)
} }
if (!loaded) { if (!loaded && npub) {
start() start()
} }
}, [bchWalletState, loaded, setProfile]) }, [bchWalletState, loaded, setProfile, npub])
return ( return (
<Container> <Container>
@@ -8,8 +8,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons' import { faUser } from '@fortawesome/free-solid-svg-icons'
import NostrFormat from '../nostr-format' import NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr' import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
function PublicRead (props) { function PublicRead (props) {
const { npub } = props
const { bchWalletState } = props.appData const { bchWalletState } = props.appData
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
@@ -17,13 +19,15 @@ function PublicRead (props) {
useEffect(() => { useEffect(() => {
// Get Last post from a author // Get Last post from a author
const start = () => { const start = () => {
const { nostrKeyPair } = bchWalletState const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
console.log('pubhex', pubHex)
const psf = 'wss://nostr-relay.psfoundation.info' const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf]) const pool = RelayPool([psf])
pool.on('open', relay => { pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [1], authors: [nostrKeyPair.pubHex] }) relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] })
setLoaded(true)
}) })
pool.on('eose', relay => { pool.on('eose', relay => {
@@ -38,10 +42,10 @@ function PublicRead (props) {
}) })
} }
if (!loaded) { if (!loaded && npub) {
start() start()
} }
}, [bchWalletState, loaded]) }, [bchWalletState, loaded, npub])
return ( return (
<Container className='mt-4'> <Container className='mt-4'>
+2 -2
View File
@@ -15,7 +15,7 @@ import Logo from './psf-logo.png'
function NavMenu (props) { function NavMenu (props) {
// Get the current path // Get the current path
const { currentPath } = props.appData const { currentPath, bchWalletState } = props.appData
// Navbar state // Navbar state
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
@@ -72,7 +72,7 @@ function NavMenu (props) {
</NavLink> </NavLink>
<NavLink <NavLink
className={currentPath === '/nostr-read' ? 'nav-link-active' : 'nav-link-inactive'} className={currentPath === '/nostr-read' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-read' to={`/nostr-read/${bchWalletState?.nostrKeyPair?.npub}`}
onClick={handleClickEvent} onClick={handleClickEvent}
> >
Nostr Profile Nostr Profile