Compare commits

...
43 Commits
Author SHA1 Message Date
Chris Troutner a7c3fc243b Merge pull request #51 from Permissionless-Software-Foundation/dh-nostr-relays
feat(nostr): Handle multiple nostr relays
2025-08-13 08:04:01 -07:00
Chris Troutner 173cda717e Adding comments 2025-08-13 08:02:50 -07:00
Chris Troutner e46068165f Removing completed todos 2025-08-13 08:00:52 -07:00
Daniel Gonzalez 086deb4904 feat(nostr): Handle multiple nostr relays 2025-08-12 18:51:33 -04:00
Chris Troutner 5386a07a62 Merge pull request #50 from Permissionless-Software-Foundation/ct-unstable
fix(bch-dex-lib): Updating to v2.2.0
2025-08-10 11:08:07 -07:00
Chris Troutner a240fab588 fix(bch-dex-lib): Updating to v2.2.0 2025-08-10 11:04:58 -07:00
Chris Troutner 0ab5702b48 Merge pull request #49 from Permissionless-Software-Foundation/ct-unstable
fix(feeds): fixing duplicate issues. Adding TODO item
2025-08-08 08:09:07 -07:00
Chris Troutner 97dd816330 fix(feeds): fixing duplicate issues. Adding TODO item 2025-08-08 08:07:53 -07:00
Chris Troutner b878939456 Merge pull request #48 from Permissionless-Software-Foundation/ct-unstable
fix(feeds): Removing duplicate posts
2025-08-06 19:49:30 -07:00
Chris Troutner 59cfecb485 fix(feeds): Removing duplicate posts 2025-08-06 19:48:09 -07:00
Chris Troutner bd3798f5aa Merge pull request #47 from Permissionless-Software-Foundation/dh-feed-pfps
feat(nostr): Added profile photos on feeds
2025-08-06 19:37:25 -07:00
Daniel Gonzalez 5f0103ba81 Merge branch 'master' into dh-feed-pfps 2025-08-06 21:54:44 -04:00
Daniel Gonzalez 55ebb7b25d feat(nostr): Added profile photos on feeds 2025-08-06 21:53:11 -04:00
Chris Troutner 44246a78d5 Merge pull request #46 from Permissionless-Software-Foundation/ct-unstable
fix(content creators): Fixing bug. Adding TODO items
2025-08-06 12:23:09 -07:00
Chris Troutner b32bbdae0a fix(content creators): Fixing bug. Adding TODO items 2025-08-06 12:21:03 -07:00
Chris Troutner f9c31b61c7 Merge pull request #45 from Permissionless-Software-Foundation/ct-unstable
fix(Nostr): Reading from pool of relays whenever we can
2025-08-04 12:55:46 -07:00
Chris Troutner e327704b57 fix(Nostr): Reading from pool of relays whenever we can 2025-08-04 12:51:16 -07:00
Chris Troutner c84b8ed9c0 Merge pull request #44 from Permissionless-Software-Foundation/ct-unstable
Minor design improvements
2025-08-04 12:38:59 -07:00
Chris Troutner 59f49f9813 fix(Token Info): Turning 'View User Data' link into a button 2025-08-04 12:37:41 -07:00
Chris Troutner 44537e9cdd fix(Nostr Profile Post): Posting to multiple relays 2025-08-04 12:22:19 -07:00
Chris Troutner e547c97a44 fix(Nostr Public Post): Posting to multiple Nostr relays 2025-08-04 12:18:49 -07:00
Chris Troutner 880a07819c fix(Nostr Post): Opening up public post on page load 2025-08-04 11:53:44 -07:00
Chris Troutner a1f2c78059 Merge pull request #43 from Permissionless-Software-Foundation/dh-userdata-loading
feat(user-data): Added loading optimization to user-data page
2025-08-03 08:32:24 -07:00
Daniel Gonzalez 1d6ebbfd92 feat(user-data): Added loading optimization to user-data page 2025-08-02 20:22:16 -04:00
Chris Troutner 7e168c6cf1 Merge pull request #42 from Permissionless-Software-Foundation/dh-tokens-sale
feat(profile): Added tokens-for-sale section to profile
2025-08-01 10:54:41 -07:00
Daniel Gonzalez b031fa98de feat(profile): Added tokens-for-sale section to profile 2025-08-01 12:43:01 -04:00
Chris Troutner d708d88c3c Merge pull request #41 from Permissionless-Software-Foundation/dh-loading-time
feat(nostr): used '#' to reduce page loading time on profile page
2025-07-30 08:03:39 -07:00
Daniel Gonzalez f80c43f35e feat(nostr): used '#' to reduce page loading time on profile page 2025-07-29 20:00:14 -04:00
Daniel Gonzalez 3d73b7943d Fixed public read npub 2025-07-29 18:22:18 -04:00
Daniel Gonzalez 13ad04cde1 Fixed profile npub 2025-07-29 18:15:50 -04:00
Chris Troutner f3dd9f3d07 Merge pull request #40 from Permissionless-Software-Foundation/dh-pfp
feat(nostr): Added PFPs on /content-creators page
2025-07-29 10:44:58 -07:00
Daniel Gonzalez 5e6a90f560 feat(nostr): Added PFPs on /content-creators page 2025-07-28 16:47:18 -04:00
Chris Troutner ce410ddc9c Merge pull request #39 from Permissionless-Software-Foundation/dh-like-posts
feat(nostr): Implemented 'Like' buttons functionality
2025-07-26 06:06:34 -07:00
Daniel Gonzalez 67bd90be8f feat(nostr): Implemented 'Like' buttons functionality 2025-07-25 18:01:09 -04:00
Chris Troutner 10ef73f542 Merge pull request #38 from Permissionless-Software-Foundation/dh-extra-data
feat(nostr): Implemented extra profile data
2025-07-25 06:45:44 -07:00
Chris Troutner 0ad9147e25 Merge branch 'master' into dh-extra-data 2025-07-25 06:43:08 -07:00
Chris Troutner 53c050a856 Merge pull request #37 from Permissionless-Software-Foundation/dh-feed-tabs
feat(nostr): Added tabs to Feeds page
2025-07-25 06:37:40 -07:00
Daniel Gonzalez 323f9e4b0a feat(nostr): Implemented extra profile data 2025-07-22 20:26:51 -04:00
Daniel Gonzalez ad79d61dc1 feat(nostr): Added tabs to Feeds page 2025-07-22 12:29:21 -04:00
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
30 changed files with 1644 additions and 617 deletions
+4 -4
View File
@@ -15,7 +15,7 @@
"@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "^1.8.0",
"axios": "0.27.2",
"bch-dex-lib": "2.0.3",
"bch-dex-lib": "2.2.0",
"bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1",
@@ -10043,9 +10043,9 @@
"license": "MIT"
},
"node_modules/bch-dex-lib": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/bch-dex-lib/-/bch-dex-lib-2.0.3.tgz",
"integrity": "sha512-aPZIlDcRziZBRKRhEuAv6G/skFjZ28+sE30fkoUY8isUIAe3C0YS7yQMoOmGSA6N46/XuhRbfQi0SRQJfrASpA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/bch-dex-lib/-/bch-dex-lib-2.2.0.tgz",
"integrity": "sha512-SORqn4qNmbxfL2SbbapOpbcCB+FtqasR+GT0XRifdkXFjA3aDL0gwMsHjh8IzLZnKtPYSBcfgzKQFxEsXCJNug==",
"license": "MIT",
"dependencies": {
"@chris.troutner/retry-queue": "1.0.10",
+1 -1
View File
@@ -9,7 +9,7 @@
"@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "^1.8.0",
"axios": "0.27.2",
"bch-dex-lib": "2.0.3",
"bch-dex-lib": "2.2.0",
"bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1",
+42 -3
View File
@@ -14,6 +14,10 @@ import NavMenu from './components/nav-menu'
import useAppState from './hooks/state'
import { UninitializedView, InitializedView } from './components/starter-views'
// Paths with custom async loader
// Single Views, load minimal async services in order to prevent the long-time screen loader.
const sigleViewPaths = ['/profile', 'user-data']
function App (props) {
// Load all the app state into a single object that can be passed to child
// components.
@@ -30,12 +34,47 @@ function App (props) {
})
}, [])
const isSignleView = useCallback(async () => {
// Get current path
const currentPath = window.location.pathname
// Get hash from url
const hash = window.location.hash
const allowedPath = sigleViewPaths.find((val) => { return currentPath.match(val) })
if (hash === '#single-view' && allowedPath) {
addToModal('Loading minimal-slp-wallet', appData)
const asyncLoad = new AsyncLoad()
if (!appData.wallet) {
await asyncLoad.loadWalletLib()
const walletTemp = await asyncLoad.initStarterWallet(appData.serverUrl, appData.lsState.mnemonic, appData)
appData.setWallet(walletTemp)
// Get the BCH spot price
addToModal('Getting BCH spot price in USD', appData)
await asyncLoad.getUSDExchangeRate(walletTemp, appData.updateBchWalletState, appData)
}
// Update Modal State
appData.setIsSingleView(true)
appData.setHideSpinner(true)
appData.setShowStartModal(false)
appData.setDenyClose(false)
/* // Update the startup state.
appData.setAsyncInitFinished(true)
appData.setAsyncInitSucceeded(false) */
return true
}
return false
}, [appData, addToModal])
/** Load all required data before component start. */
useEffect(() => {
async function asyncEffect () {
console.log('asyncInitStarted: ', appData.asyncInitStarted)
const singleView = await isSignleView()
if (!appData.asyncInitStarted) {
console.log('asyncInitStarted: ', appData.asyncInitStarted)
console.log('is single view : ', singleView)
if (!appData.asyncInitStarted && !singleView) {
try {
// Instantiate the async load object.
const asyncLoad = new AsyncLoad()
@@ -112,7 +151,7 @@ function App (props) {
}
}
asyncEffect()
}, [appData, addToModal])
}, [appData, addToModal, isSignleView])
return (
<>
+2 -2
View File
@@ -25,7 +25,7 @@ import SignMessage from './sign/index.js'
import ServerSelectView from './configuration/select-server-view'
import NostrPost from './nostr/nostr-post/index.js'
import Profile from './nostr/profile/index.js'
import GlobalFeed from './nostr/global-feed/index.js'
import Feeds from './nostr/feeds/index.js'
import ContentCreators from './nostr/content-creators/index.js'
import UserDataReview from './user-data-review'
import Offers from './offers'
@@ -50,7 +50,7 @@ function AppBody (props) {
<Route path='/configuration' element={<ServerSelectView appData={appData} />} />
<Route path='/nostr-post' element={<NostrPost appData={appData} />} />
<Route path='/profile/:npub' element={<Profile appData={appData} />} />
<Route path='/global-feed' element={<GlobalFeed appData={appData} />} />
<Route path='/feeds' element={<Feeds appData={appData} />} />
<Route path='/content-creators' element={<ContentCreators appData={appData} />} />
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
<Route path='/offers' element={<Offers appData={appData} />} />
@@ -172,7 +172,7 @@ function NftsForSale (props) {
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenMutableData, appData])
}, [fetchTokenMutableData])
// Check if token data exists in the cache and add it to the tokens object.
const reviewNftCachedData = useCallback(async (offers) => {
@@ -98,17 +98,16 @@ function InfoButton (props) {
</Row>
{mutableDataCid && (
<Row>
<Row style={{ paddingTop: '10px' }}>
<Col xs={4}><b>User Data</b>:</Col>
<Col xs={8}>
<a
href={`/user-data/${props.token.tokenId}`}
<Button
href={`/user-data/${props.token.tokenId}#single-view`}
target='_blank'
rel='noopener noreferrer'
className='btn btn-link p-0'
>
View User Data
</a>
</Button>
</Col>
</Row>
)}
@@ -12,7 +12,7 @@ import InfoButton from './info-button'
import BuyButton from './buy-button'
function TokenCard (props) {
const { token, appData, handleRefresh } = props
const { token, appData, handleRefresh, hideBuyBtn } = props
const [icon, setIcon] = useState(token.icon)
const [tokenData, setTokenData] = useState(token.tokenData)
@@ -64,16 +64,16 @@ function TokenCard (props) {
</Row>
<br />
<Row>
<Row className='text-center'>
<Col>
<InfoButton token={token} disabled={!token.tokenData} />
</Col>
<Col />
<Col>
<BuyButton token={token} disabled={!token.tokenData} appData={appData} onSuccess={handleRefresh} />
</Col>
{!hideBuyBtn && (
<Col>
<BuyButton token={token} disabled={!token.tokenData} appData={appData} onSuccess={handleRefresh} />
</Col>
)}
</Row>
</Container>
@@ -83,17 +83,5 @@ function TokenCard (props) {
</>
)
}
// eslint-disable-next-line
{/* <Col>
<InfoButton token={props.token} />
</Col>
<Col>
<FlagButton appData={props.appData} offer={props.token} />
</Col>
<Col>
<BuyNftButton appData={props.appData} offer={props.token} />
</Col> */ }
export default TokenCard
@@ -3,53 +3,29 @@
*/
import React, { useState, useEffect } from 'react'
import { Card } from 'react-bootstrap'
import { Card, Spinner } 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, followList, refreshFollowList } = props
const [profile, setProfile] = useState(null)
const [imageError, setImageError] = useState(false)
useEffect(() => {
const loadProfile = async () => {
const psf = 'wss://nostr-relay.psfoundation.info'
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])
setProfile(creator?.profile)
}, [creator.profile])
const goToProfile = () => {
const profileUrl = `${window.location.origin}/profile/${creator.npub}`
const profileUrl = `${window.location.origin}/profile/${creator.npub}#single-view`
window.open(profileUrl, '_blank')
}
const handleImageError = (type) => {
setImageError(true)
}
return (
<>
@@ -63,14 +39,38 @@ function ContentCard (props) {
</div>
<div className='d-none d-md-flex align-items-center gap-4 cursor-pointer'>
{/* Profile Picture */}
<div className='flex-shrink-0' onClick={goToProfile}>
<Jdenticon size='140' value={creator.npub} />
</div>
{(profile && profile.picture && !imageError) && (
<div className='flex-shrink-0 max-w-50' onClick={goToProfile}>
<img
src={profile.picture}
alt='Profile'
className='rounded-circle shadow'
style={{ objectFit: 'cover', width: '140px' }}
onError={() => handleImageError('picture')}
/>
</div>
)}
{/* Default profile Picture */}
{((profile && !profile.picture) || imageError) && (
<div className='flex-shrink-0' onClick={goToProfile}>
<Jdenticon size='140' value={creator.npub} />
</div>
)}
{/** */}
{!profile && (
<div
className='flex-shrink-0'
style={{ width: '140px', height: '140px', display: 'flex', justifyContent: 'center', alignItems: 'center' }}
>
<Spinner size='140' />
</div>
)}
{/* Creator Info */}
<div className='flex-grow-1'>
<h5 className='mb-2 fw-bold cursor-pointer' onClick={goToProfile}>{profile.name}</h5>
<p className='text-muted medium mb-3'>{profile.about}</p>
<h5 className='mb-2 fw-bold cursor-pointer' onClick={goToProfile}>{profile?.name}</h5>
<p className='text-muted medium mb-3'>{profile?.about}</p>
{/* Nostr Public Key */}
<div className='mb-2'>
@@ -126,21 +126,54 @@ function ContentCard (props) {
Message
</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>
{/* Mobile Layout - Vertical */}
<div className='d-flex d-md-none flex-column align-items-center text-center'>
{/* Profile Picture */}
<div className='mb-3'>
<Jdenticon size='100' value={creator.npub} onClick={goToProfile} />
</div>
{(profile && profile.picture && !imageError) && (
<div className='flex-shrink-0 max-w-50 mb-2' onClick={goToProfile}>
<img
src={profile.picture}
alt='Profile'
className='rounded-circle shadow'
style={{ objectFit: 'cover', width: '100px' }}
onError={() => handleImageError('picture')}
/>
</div>
)}
{/* Default profile Picture */}
{((profile && !profile.picture) || imageError) && (
<div className='flex-shrink-0 mb-2' onClick={goToProfile}>
<Jdenticon size='100' value={creator.npub} />
</div>
)}
{/** */}
{!profile && (
<div
className='flex-shrink-0'
style={{ width: '100px', height: '100px', display: 'flex', justifyContent: 'center', alignItems: 'center' }}
>
<Spinner size='100' />
</div>
)}
{/* Creator Info */}
<div className='w-100 mb-3'>
<h5 className='mb-2 fw-bold' onClick={goToProfile}>{profile.name}</h5>
<p className='text-muted small mb-3'>{profile.about}</p>
<h5 className='mb-2 fw-bold' onClick={goToProfile}>{profile?.name}</h5>
<p className='text-muted small mb-3'>{profile?.about}</p>
{/* Nostr Public Key */}
<div className='mb-3'>
@@ -194,6 +227,13 @@ function ContentCard (props) {
Message
</button>
</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>
</Card.Body>
</Card>
@@ -1,7 +1,21 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Spinner, Form } from 'react-bootstrap'
/*
This component is used to display the list of content creators.
TODO:
- getFollowList() should aggregate all the followers from all the relays.
Currently each relay overwrites the last one.
- loadProfile() should use multiple relays. It should exit after the first
successful retrieval of data from a relay. If the relay fails to give data,
it should move on to the next relay.
*/
import React, { useState, useEffect, useCallback } from 'react'
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'
// Local libraries
import config from '../../../../config'
import ContentCard from './content-card'
@@ -12,48 +26,108 @@ const SERVER = config.dexServer
function ContentCreators (props) {
const [creators, setCreators] = useState([])
const [loaded, setLoaded] = useState(false)
const [selectedFilter, setSelectedFilter] = useState('Most Followers')
const { appData } = props
const [followList, setFollowList] = useState([])
// Get the list of profiles followed by the user.
// It aggregates all the followers from all the relays.
const getFollowList = useCallback(async () => {
const list = await new Promise((resolve, reject) => {
let list = []
let closedRelays = 0
const { nostrKeyPair } = appData.bchWalletState
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
const pool = RelayPool(config.nostrRelays)
// const pool = RelayPool([config.nostrRelay])
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)
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === config.nostrRelays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
list = ev.tags
resolve(list)
// console.log('Received event:', ev)
// Merge list received from all relays
list = [...list, ...ev.tags]
})
})
console.log('Follow List', list)
// console.log('Follow List', list)
setFollowList(list)
}, [appData])
// Load profile from nostr relays
// It uses multiple relays. It will exit after the first successful retrieval
// from any relay. If one relay fails, it will move on to the next one.
const loadProfile = useCallback(async (pubKey) => {
// Looking for the profile in each relay sequentially
for (let i = 0; i < config.nostrRelays.length; i++) {
const profile = await new Promise((resolve) => {
const relay = config.nostrRelays[i]
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([relay])
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)
console.log(`Profile found for ${pubKey} at ${relay.url}`)
resolve(profile)
} catch (error) {
resolve(false)
}
relay.close()
})
})
// Stop looking for profile if found
if (profile) {
return profile
}
}
}, [])
useEffect(() => {
const loadCreators = async () => {
try {
const creators = await axios.get(`${SERVER}/sm/list/all/0`)
console.log(creators.data)
setCreators(creators.data)
const creatorsRes = await axios.get(`${SERVER}/sm/list/all/0`)
const creators = creatorsRes.data
// console.log('creators', creators)
setCreators(creators)
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) {
setLoaded(true)
}
@@ -63,7 +137,21 @@ function ContentCreators (props) {
loadCreators()
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 (
<Container className='mt-4 mb-5'>
@@ -74,13 +162,25 @@ function ContentCreators (props) {
</p>
</div>
<div className='mb-4'>
<h3>Filters</h3>
<Form.Select>
<option>Most Followers</option>
<option>Most Tokens</option>
<option>Most Likes</option>
</Form.Select>
<div className='mb-4 d-flex justify-content-end'>
<Dropdown>
<Dropdown.Toggle variant='outline-secondary' className='d-flex align-items-center gap-2'>
<FontAwesomeIcon icon={faFilter} />
<span>{selectedFilter}</span>
</Dropdown.Toggle>
<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>
{!loaded && (
@@ -92,7 +192,7 @@ function ContentCreators (props) {
{loaded && (
<div>
{creators.map((creator, i) => (
{filteredCreators().map((creator, i) => (
<ContentCard
key={`creator-key${i}`}
creator={creator}
@@ -104,7 +204,7 @@ function ContentCreators (props) {
</div>
)}
{loaded && creators.length === 0 && (
{loaded && filteredCreators().length === 0 && (
<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' />
<div>No content creators found</div>
@@ -0,0 +1,284 @@
/**
* Component for displaying nostr posts
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser, faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons'
import { faHeart } from '@fortawesome/free-regular-svg-icons'
import * as nip19 from 'nostr-tools/nip19'
import NostrFormat from '../nostr-format'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import { RelayPool } from 'nostr'
function FeedCard (props) {
const { post, appData, profiles } = props
const { nostrKeyPair } = appData.bchWalletState
const [profile, setProfile] = useState(profiles[post.pubkey])
const [npub, setNpub] = useState('')
const [isClicked, setIsClicked] = useState(false)
const [isLiked, setIsLiked] = useState(false)
const [likesCount, setLikesCount] = useState(null)
const [likesFetched, setLikesFetched] = useState(false)
const [profilePictureError, setProfilePictureError] = useState(false)
// function to fetch a post likes reaction
const handleLikes = useCallback(async (post, userPubKey) => {
try {
const psf = 'wss://nostr-relay.psfoundation.info'
const res = await new Promise((resolve) => {
let likesCnt = 0
let userLikedCnt = 0
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { kinds: [7], '#e': [post.id] })
})
pool.on('eose', relay => {
relay.close()
resolve({
count: likesCnt,
userLiked: userLikedCnt > 0
})
})
pool.on('event', (relay, subId, ev) => {
try {
// Count likes
if (ev.content === '+') {
likesCnt++
// Count user likes
if (ev.pubkey === userPubKey) {
userLikedCnt++
}
}
// Count dislikes
if (ev.content === '-') {
likesCnt--
// Count user dislikes.
if (ev.pubkey === userPubKey) {
userLikedCnt--
}
}
} catch (error) {
// skip error
}
})
})
setLikesCount(res.count)
setIsLiked(res.userLiked)
setLikesFetched(true)
} catch (error) {
console.warn(error)
}
}, [])
const handleProfilePictureError = () => {
setProfilePictureError(true)
}
const handleProfilePictureLoad = () => {
setProfilePictureError(false)
}
// Get npub from pubkey
useEffect(() => {
const npub = nip19.npubEncode(post.pubkey)
setNpub(npub)
// Handle post likes
if (!likesFetched && nostrKeyPair.pubHex) {
handleLikes(post, nostrKeyPair.pubHex)
}
}, [post, nostrKeyPair, handleLikes, likesFetched])
// Verify if pubkey profile exists into the profiles state
useEffect(() => {
if (profiles[post.pubkey]) {
setProfile(profiles[post.pubkey])
}
}, [profiles, post])
// copy to clipboard
const copyToClipboard = useCallback((value) => {
setIsClicked(true)
appData.appUtil.copyToClipboard(value)
setTimeout(() => setIsClicked(false), 200)
}, [appData])
// get short npub
const getShortNpub = useCallback((npub) => {
return npub.slice(0, 8) + '...' + npub.slice(-5)
}, [])
const submitLike = async (e) => {
// Post on nostr network
e.preventDefault()
try {
const { nostrKeyPair } = appData.bchWalletState
setLikesFetched(false)
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
const psf = 'wss://nostr-relay.psfoundation.info'
// Define if like or dislike
let content = '+'
if (isLiked) { content = '-' }
// Generate a post.
const eventTemplate = {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', post.id, psf],
['p', psf, nostrKeyPair.pubHex]
],
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()
await handleLikes(post, nostrKeyPair.pubHex)
setLikesFetched(true)
} catch (error) {
console.warn(error)
setLikesFetched(true)
}
}
return (
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'>
<div className='d-flex align-items-center gap-3 mb-3'>
<div
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center'
style={{
width: '48px',
height: '48px',
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
{profile?.picture && !profilePictureError
? (
<img
src={profile.picture}
alt='Profile'
className='rounded-circle w-100 h-100'
style={{ objectFit: 'cover' }}
onError={handleProfilePictureError}
onLoad={handleProfilePictureLoad}
/>
)
: (
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' />
)}
</div>
<div className='flex-grow-1'>
<div className='fw-bold mb-1'>
{profile && profile.name && <span>{profile.name}</span>}
{!profile?.name && (
<span>
{post.pubkey.slice(0, 8) + '...'}
{!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />}
</span>
)}
</div>
{npub && (
<small className='text-muted'>
<span
title='Copy to clipboard'
style={{
cursor: 'pointer',
transform: isClicked ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease',
display: 'inline-block'
}}
onClick={() => copyToClipboard(npub)}
>
{getShortNpub(npub)}
</span>
</small>
)}
</div>
<small className='text-muted'>
{new Date(post.created_at * 1000).toLocaleString()}
</small>
</div>
<div className='mb-4'>
<div className='fs-5 ms-5 text-dark' style={{ lineHeight: '1.3' }}>
<NostrFormat content={post.content} />
</div>
</div>
<div className='d-flex justify-content-end'>
{likesFetched && (
<button
onClick={submitLike}
className='btn btn-link text-decoration-none p-0 d-flex align-items-center gap-2'
style={{
border: 'none',
background: 'transparent',
transition: 'all 0.2s ease',
color: isLiked ? '#e31b23' : '#536471',
fontSize: '14px',
fontWeight: '400'
}}
onMouseEnter={(e) => {
e.target.style.transform = 'scale(1.05)'
}}
onMouseLeave={(e) => {
e.target.style.transform = 'scale(1)'
}}
>
<FontAwesomeIcon
icon={isLiked ? faHeartSolid : faHeart}
style={{
fontSize: '16px',
color: isLiked ? '#e31b23' : '#536471',
transition: 'all 0.2s ease'
}}
/>
<span
style={{
fontSize: '14px',
fontWeight: '400',
color: isLiked ? '#e31b23' : '#536471',
transition: 'all 0.2s ease'
}}
>
{likesCount || 0}
</span>
</button>
)}
{!likesFetched && (
<Spinner animation='border' size='sm' className='ms-2' />
)}
</div>
</Card.Body>
</Card>
)
}
export default FeedCard
@@ -0,0 +1,30 @@
/**
* Component for displaying global nostr posts
*/
// Global npm libraries
import React from 'react'
import { Container } from 'react-bootstrap'
import FeedCard from './feed-card'
function Feed (props) {
const { posts, profiles } = props
return (
<Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}>
<div>
{posts.map((post, index) => (
<FeedCard key={index} post={post} appData={props.appData} profiles={profiles} />
))}
{!posts.length && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<i className='bi bi-inbox-fill fs-1 mb-3 d-block opacity-50' />
<div>No posts found</div>
</div>
)}
</div>
</Container>
)
}
export default Feed
@@ -0,0 +1,82 @@
/**
* Component for displaying following nostr posts
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr'
// Local libraries
import config from '../../../../config'
import FeedCard from './feed-card'
function Following (props) {
const { appData, posts, profiles } = props
const [followingPosts, setFollowingPosts] = useState([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
const getFollowingPosts = async () => {
const followingList = await new Promise((resolve, reject) => {
let list = []
const { nostrKeyPair } = appData.bchWalletState
const pool = RelayPool(config.nostrRelays)
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [3], authors: [nostrKeyPair.pubHex] })
})
pool.on('eose', 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) => {
list = ev.tags
resolve(list)
})
})
// Filter posts by following list
let filteredPosts = []
for (let i = 0; i < followingList.length; i++) {
const following = followingList[i]
const filtered = posts.filter(post => {
return following.includes(post.pubkey)
})
filteredPosts = [...filteredPosts, ...filtered]
}
// Sort filtered posts by created_at
filteredPosts.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
setFollowingPosts(filteredPosts)
setLoaded(true)
}
getFollowingPosts()
}, [appData, posts])
return (
<Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}>
<div>
{loaded && followingPosts.map((post, index) => (
<FeedCard key={index} post={post} appData={props.appData} profiles={profiles} />
))}
{loaded && !followingPosts.length && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<i className='bi bi-inbox-fill fs-1 mb-3 d-block opacity-50' />
<div>No posts found</div>
</div>
)}
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
</div>
)}
</div>
</Container>
)
}
export default Following
@@ -0,0 +1,171 @@
/*
Component for reading the nostr feeds.
TODO:
- fetchProfile() should retrieve a profile from multiple relays. If the first relay returns a profile,
then that profile can be used and promise resolved. If the first relay returns no profile, the next
one should be tried until all relays are exhausted or one returns a profile.
- useEffect() retrieves the feeds. This should cycle through each relay and posts from each one.
Once each relays has been tried, the posts should remove duplicate entries. Finally posts should
be sorted by date.
- Clicking on a profile picture, name, or npub should open the profile for that user in a new tab.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Nav, Tab, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr'
// Local libraries
import Feed from './feed'
import Following from './following'
import config from '../../../../config'
function Feeds (props) {
const { appData } = props
const [activeTab, setActiveTab] = useState(appData.lastFeedTab)
const { bchWalletState } = appData
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
const [profiles, setProfiles] = useState({})
// function to fetch profile and set it to profiles state
const fetchProfile = useCallback(async (pubkey) => {
// no fetch profile again if it exist
let hasProfileRequest = false
setProfiles(currentProfiles => {
if (currentProfiles[pubkey]) {
hasProfileRequest = true
return currentProfiles
} else {
const newProfiles = { ...currentProfiles }
newProfiles[pubkey] = { loaded: false }
return newProfiles
}
})
if (hasProfileRequest) return
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubkey] })
})
pool.on('eose', relay => {
relay.close()
try {
// Mark unknown profiles. From this way we can know which profile was fetched and not found.
setProfiles(currentProfiles => {
if (!currentProfiles[pubkey]) {
const newProfiles = { ...currentProfiles }
newProfiles[pubkey] = { loaded: true }
return newProfiles
}
return currentProfiles
})
} catch (error) {
console.warn(error)
// skip error
}
})
pool.on('event', (relay, subId, ev) => {
try {
// update profiles data
const profile = JSON.parse(ev.content)
setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles }
newProfiles[pubkey] = profile
return newProfiles
})
} catch (error) {
// skip error
}
})
}, [])
// Get global feed posts
useEffect(() => {
const start = () => {
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('REQ', { limit: 10, kinds: [1], '#t': ['slpdex-socialmedia'] })
})
pool.on('eose', relay => {
setLoaded(true)
relay.close()
})
pool.on('event', async (relay, subId, ev) => {
setPosts(currentPosts => [...currentPosts, ev])
// fetch post profile and set it to profiles state
fetchProfile(ev.pubkey)
})
}
if (!loaded) {
start()
}
}, [bchWalletState, loaded, fetchProfile])
const onChangeTab = (tab) => {
setActiveTab(tab)
appData.setLastFeedTab(tab)
appData.updateLocalStorage({ lastFeedTab: tab })
}
return (
<>
<Container>
<h2 style={{
textAlign: 'center',
margin: '20px 0 30px 0',
padding: '10px',
borderBottom: '2px solid #ccc'
}}
>
Feeds
</h2>
{loaded && (
<Tab.Container activeKey={activeTab} onSelect={onChangeTab}>
<Nav variant='tabs' className='mb-4'>
<Nav.Item>
<Nav.Link eventKey='feed' className={activeTab === 'feed' ? 'active' : ''}>
Feeds
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey='following' className={activeTab === 'following' ? 'active' : ''}>
Following
</Nav.Link>
</Nav.Item>
</Nav>
<Tab.Content>
<Tab.Pane eventKey='feed'>
<Feed appData={appData} posts={posts} profiles={profiles} />
</Tab.Pane>
<Tab.Pane eventKey='following'>
<Following appData={appData} posts={posts} profiles={profiles} />
</Tab.Pane>
</Tab.Content>
</Tab.Container>
)}
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
</div>
)}
</Container>
</>
)
}
export default Feeds
@@ -1,100 +0,0 @@
/**
* Component for reading global nostr posts
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import * as nip19 from 'nostr-tools/nip19'
import NostrFormat from '../nostr-format'
function FeedCard (props) {
const { post, appData, profiles } = props
const [profile, setProfile] = useState(profiles[post.pubkey])
const [npub, setNpub] = useState('')
const [isClicked, setIsClicked] = useState(false)
// Get npub from pubkey
useEffect(() => {
const npub = nip19.npubEncode(post.pubkey)
setNpub(npub)
}, [post])
// Verify if pubkey profile exists into the profiles state
useEffect(() => {
if (profiles[post.pubkey]) {
setProfile(profiles[post.pubkey])
}
}, [profiles, post])
// copy to clipboard
const copyToClipboard = useCallback((value) => {
setIsClicked(true)
appData.appUtil.copyToClipboard(value)
setTimeout(() => setIsClicked(false), 200)
}, [appData])
// get short npub
const getShortNpub = useCallback((npub) => {
return npub.slice(0, 8) + '...' + npub.slice(-5)
}, [])
return (
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'>
<div className='d-flex align-items-center gap-3 mb-3'>
<div
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center'
style={{
width: '48px',
height: '48px',
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' />
</div>
<div className='flex-grow-1'>
<div className='fw-bold mb-1'>
{profile && profile.name && <span>{profile.name}</span>}
{!profile?.name && (
<span>
{post.pubkey.slice(0, 8) + '...'}
<Spinner animation='border' size='sm' className='ms-2' />
</span>
)}
</div>
{npub && (
<small className='text-muted'>
<span
title='Copy to clipboard'
style={{
cursor: 'pointer',
transform: isClicked ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease',
display: 'inline-block'
}}
onClick={() => copyToClipboard(npub)}
>
{getShortNpub(npub)}
</span>
</small>
)}
</div>
<small className='text-muted'>
{new Date(post.created_at * 1000).toLocaleString()}
</small>
</div>
<div className='mb-4'>
<div className='fs-5 ms-5 text-dark' style={{ lineHeight: '1.3' }}>
<NostrFormat content={post.content} />
</div>
</div>
</Card.Body>
</Card>
)
}
export default FeedCard
@@ -1,104 +0,0 @@
/**
* Component for reading global nostr posts
*/
// Global npm libraries
import React, { useEffect, useState, useCallback } from 'react'
import { Container, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr'
import FeedCard from './feed-card'
function Feed (props) {
const { appData } = props
const { bchWalletState } = appData
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
const [profiles, setProfiles] = useState({})
// function to fetch profile and set it to profiles state
const fetchProfile = useCallback((pubkey) => {
// no fetch profile again if it exist
if (profiles[pubkey]) {
console.log('profile exists', profiles[pubkey])
return profiles[pubkey]
}
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()
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
const newProfiles = { ...profiles }
newProfiles[pubkey] = profile
setProfiles(newProfiles)
} catch (error) {
console.log('Error parsing profile', error)
}
})
}, [profiles])
// Get global feed posts
useEffect(() => {
const start = () => {
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('REQ', { limit: 10, kinds: [1], '#t': ['slpdex-socialmedia'] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
setLoaded(true)
relay.close()
})
pool.on('event', async (relay, subId, ev) => {
console.log('Received event:', ev)
setPosts(currentPosts => [...currentPosts, ev])
// fetch post profile and set it to profiles state
fetchProfile(ev.pubkey)
})
}
if (!loaded) {
start()
}
}, [bchWalletState, loaded, fetchProfile])
return (
<Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}>
<div>
{posts.map((post, index) => (
<FeedCard key={index} post={post} appData={props.appData} profiles={profiles} />
))}
{!posts.length && loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<i className='bi bi-inbox-fill fs-1 mb-3 d-block opacity-50' />
<div>No posts found</div>
</div>
)}
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
</div>
)}
</div>
</Container>
)
}
export default Feed
@@ -1,31 +0,0 @@
/*
Component for reading the nostr global feed.
*/
// Global npm libraries
import React from 'react'
import { Container } from 'react-bootstrap'
import Feed from './feed'
function GlobalFeed (props) {
const { appData } = props
return (
<>
<Container>
<h2 style={{
textAlign: 'center',
margin: '20px 0 50px 0',
padding: '10px',
borderBottom: '2px solid #ccc'
}}
>
Global Feed
</h2>
<Feed appData={appData} />
</Container>
</>
)
}
export default GlobalFeed
@@ -26,7 +26,7 @@ const NostrFormat = ({ content }) => {
}
return (
<div dangerouslySetInnerHTML={{ __html: formatContent(content) }} />
<div style={{ overflow: 'auto' }} dangerouslySetInnerHTML={{ __html: formatContent(content) }} />
)
}
@@ -1,10 +1,12 @@
/*
Component for posting nostr information.
Component for posting nostr information.
*/
// Global npm libraries
import React from 'react'
import { Container } from 'react-bootstrap'
// Local libraries
import ProfilePost from './profile-post'
import PublicPost from './public-post'
@@ -3,17 +3,22 @@ Component for posting nostr information on kind 0.
*/
// Global npm libraries
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { Container, Form, Button, Spinner } from 'react-bootstrap'
import Accordion from 'react-bootstrap/Accordion'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import { RelayPool } from 'nostr'
// Local libraries
import config from '../../../../config'
function ProfilePost (props) {
const { bchWalletState } = props.appData
const [accordionKey, setAccordionKey] = useState(null)
const [onFetch, setOnFetch] = useState(false)
const { bchWalletState } = props.appData
const [formLoaded, setFormLoaded] = useState(false)
const [formData, setFormData] = useState({
name: '',
about: '',
@@ -27,6 +32,43 @@ function ProfilePost (props) {
const [errorMsg, setErrorMsg] = 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 { name, value } = e.target
setFormData({
@@ -51,7 +93,7 @@ function ProfilePost (props) {
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
const psf = 'wss://nostr-relay.psfoundation.info'
// const psf = 'wss://nostr-relay.psfoundation.info'
const formDataString = JSON.stringify(formData)
@@ -68,17 +110,24 @@ function ProfilePost (props) {
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 post to each relay.
config.nostrRelays.map(async (relayUrl) => {
try {
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Close the connection to the relay.
relay.close()
} catch (err) {
console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`)
}
})
// Close the connection to the relay.
relay.close()
resetForm()
setSuccessMsg('Post successfully published!')
setOnFetch(false)
} catch (error) {
@@ -92,18 +141,6 @@ function ProfilePost (props) {
setAccordionKey(key)
}
const resetForm = () => {
setFormData({
name: '',
about: '',
picture: '',
display_name: '',
website: '',
banner: '',
bot: false
})
}
return (
<>
<Container className='mt-4'>
@@ -121,103 +158,111 @@ function ProfilePost (props) {
{successMsg}
</div>
)}
<Form onSubmit={handleSubmit}>
<Form.Group className='mb-3'>
<Form.Label>Name</Form.Label>
<Form.Control
type='text'
placeholder='Enter your name'
name='name'
value={formData.name}
onChange={handleInputChange}
required
/>
</Form.Group>
{formLoaded && (
<Form onSubmit={handleSubmit}>
<Form.Group className='mb-3'>
<Form.Label>Name</Form.Label>
<Form.Control
type='text'
placeholder='Enter your name'
name='name'
value={formData.name}
onChange={handleInputChange}
required
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>About</Form.Label>
<Form.Control
as='textarea'
rows={3}
placeholder='Tell us about yourself'
name='about'
value={formData.about}
onChange={handleInputChange}
required
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>About</Form.Label>
<Form.Control
as='textarea'
rows={3}
placeholder='Tell us about yourself'
name='about'
value={formData.about}
onChange={handleInputChange}
required
/>
</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>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>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>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.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>
<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'>
{!onFetch && (
<Button variant='primary' type='submit' disabled={onFetch}>
Post
</Button>
)}
{onFetch && <Spinner animation='border' variant='primary' />}
<div className='d-flex justify-content-center'>
{!onFetch && (
<Button variant='primary' type='submit' disabled={onFetch}>
Post
</Button>
)}
{onFetch && <Spinner animation='border' variant='primary' />}
</div>
</Form>
)}
{!formLoaded && (
<div className='text-center'>
<Spinner animation='border' variant='primary' />
</div>
)}
</Form>
</Accordion.Body>
</Accordion.Item>
</Accordion>
@@ -1,5 +1,5 @@
/*
Component for posting nostr information on kind 1.
Component for posting nostr information on kind 1.
*/
// Global npm libraries
@@ -10,8 +10,11 @@ import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
// Local libraries
import config from '../../../../config'
function PublicPost (props) {
const [accordionKey, setAccordionKey] = useState(null)
const [accordionKey, setAccordionKey] = useState('0')
const [onFetch, setOnFetch] = useState(false)
const { bchWalletState } = props.appData
const [formData, setFormData] = useState({
@@ -44,9 +47,6 @@ function PublicPost (props) {
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
const psf = 'wss://nostr-relay.psfoundation.info'
// BCH address of the user.
const bchAddr = bchWalletState.address
@@ -63,16 +63,24 @@ function PublicPost (props) {
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 post to each relay.
config.nostrRelays.map(async (relayUrl) => {
try {
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Close the connection to the relay.
relay.close()
} catch (err) {
console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`)
}
})
// Close the connection to the relay.
relay.close()
resetForm()
setSuccessMsg('Post successfully published!')
setOnFetch(false)
@@ -9,7 +9,7 @@ import ProfileRead from './profile-read.js'
import PublicRead from './public-read.js'
import { useParams } from 'react-router-dom'
import SlpTokensDisplay from './slp-tokens-display'
import NFTForSale from './nft-for-sale.js'
function Profile (props) {
const [profile, setProfile] = useState(false)
const { npub } = useParams()
@@ -20,6 +20,7 @@ function Profile (props) {
<ProfileRead {...props} setProfile={setProfile} npub={npub} />
<PublicRead {...props} profile={profile} npub={npub} />
<SlpTokensDisplay {...props} npub={npub} />
<NFTForSale {...props} npub={npub} />
</Container>
</>
)
@@ -0,0 +1,321 @@
/**
* Component for displaying SLP tokens in a grid layout
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Spinner } from 'react-bootstrap'
import config from '../../../../config'
import axios from 'axios'
import TokenCard from '../../nfts-for-sale/token-card'
function NFTForSale (props) {
const { appData, npub } = props
const [offers, setOffers] = useState([])
const [offersAreLoaded, setOffersAreLoaded] = useState(false)
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [dataAreLoaded, setDataAreLoaded] = useState(false)
const getAddressByNpub = useCallback(async () => {
const url = `${config.dexServer}/sm/npub/${npub}`
const response = await axios.get(url)
const addr = response.data.bchAddr
return addr
}, [npub])
// Handler for refresh button
const handleRefresh = () => {
loadNftOffers()
}
// Function to process token data
const processTokenData = useCallback(async (offer) => {
try {
const { wallet, bchWalletState } = appData
const { bchjs } = wallet
// Calculate USD price
const rateInSats = parseInt(offer.rateInBaseUnit)
const bchCost = bchjs.BitcoinCash.toBitcoinCash(rateInSats)
const usdPrice = bchCost * bchWalletState.bchUsdPrice * offer.numTokens
offer.usdPrice = `$${usdPrice.toFixed(3)}`
return offer
} catch (err) {
console.error('Error processing token:', err)
return offer
}
}, [appData])
// Fetch offers
const getNftOffers = useCallback(async (page = 0) => {
try {
setOffersAreLoaded(false)
const addr = await getAddressByNpub(npub)
const url = `${config.dexServer}/offer/list/addr/${addr}`
const result = await axios.get(url)
const rawOffers = result.data
console.log('rawOffers: ', rawOffers)
// Process each offer
const processedOffers = []
for (let i = 0; i < rawOffers.length; i++) {
const offer = rawOffers[i]
const processedOffer = await processTokenData(offer)
processedOffers.push(processedOffer)
}
setOffersAreLoaded(true)
return processedOffers
} catch (err) {
console.error('getNftOffers error:', err)
setOffersAreLoaded(true)
throw err
}
}, [processTokenData, getAddressByNpub, npub])
// This function loads the token data .
const lazyLoadTokenData = useCallback(async (tokens) => {
try {
setDataAreLoaded(false)
// map each token and fetch the token data
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// data does not need to be downloaded, so continue with the next one
if (thisToken.dataAlreadyDownloaded) continue
// Try to get token data.
const tokenData = await appData.wallet.getTokenData(thisToken.tokenId)
console.log('tokenData', tokenData)
if (tokenData) {
// Set data to the token object , this can be used to display the token name in the token card component.
thisToken.tokenData = tokenData
}
// Mark token to prevent fetch token data again.
thisToken.dataAlreadyDownloaded = true
}
setDataAreLoaded(true)
} catch (error) {
setDataAreLoaded(true)
}
}, [appData])
// Fetch mutable data if it exist and get the token icon url
const fetchTokenMutableData = useCallback(async (token) => {
try {
// Get the token data
const tokenData = token.tokenData
if (!tokenData.mutableData) return false // Return false if no mutable data
// Get the token icon from the mutable data
const cid = parseCid(tokenData.mutableData)
console.log('mutable data cid', cid)
const { json } = await appData.wallet.cid2json({ cid })
console.log('json: ', json)
if (!json) return false
let iconUrl = json.tokenIcon
if (json.fullSizedUrl && json.fullSizedUrl.includes('http')) {
iconUrl = json.fullSizedUrl
}
const userData = json.userData
// Return icon url
return { iconUrl, userData }
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadMutableData = useCallback(async (tokens) => {
try {
setIconsAreLoaded(false)
// map each token and fetch the icon url
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// Incon does not need to be downloaded, so continue with the next one
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const { iconUrl, userData } = await fetchTokenMutableData(thisToken)
console.log('iconUrl', iconUrl)
if (iconUrl) {
// Set the icon url to the token , this can be used to display the icon in the token card component.
thisToken.icon = iconUrl
thisToken.tokenData.userData = userData
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
}
setIconsAreLoaded(true)
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenMutableData])
// Check if token data exists in the cache and add it to the tokens object.
const reviewNftCachedData = useCallback(async (offers) => {
const cacheData = appData.nftForSaleCacheData
console.log(`Token data from cache: ${JSON.stringify(cacheData, null, 2)}`)
// Map all offers
for (let i = 0; i < offers.length; i++) {
const thisToken = offers[i]
const cacheToken = cacheData[thisToken.tokenId]
// If cache data exists, add it to the token object
if (cacheToken) {
thisToken.tokenData = cacheToken.tokenData
thisToken.icon = cacheToken.tokenIcon
}
}
}, [appData])
// Check if token data exists in the cache and add it to the tokens object.
const updateNFTCachedData = useCallback(async (offers) => {
// Map all offers
for (let i = 0; i < offers.length; i++) {
const thisToken = offers[i]
// save token icon and token data in cache
const newTokenCacheData = {
tokenIcon: thisToken.icon,
tokenData: thisToken.tokenData
}
console.log('newTokenCacheData: ', newTokenCacheData)
appData.updateNFTCachedData(thisToken.tokenId, newTokenCacheData)
}
}, [appData])
// Main function to load NFT offers
const loadNftOffers = useCallback(async () => {
try {
// Get tokens in offers
const offers = await getNftOffers()
console.log('offers: ', offers)
// Review cached data to populate token data from cached data
await reviewNftCachedData(offers)
// set state to start displaying tokens cards.
setOffers(offers)
// Load tokens data for any updates to the token data
await lazyLoadTokenData(offers)
// Load tokens icons from mutable data
await lazyLoadMutableData(offers)
// Update cached data with the latest retrieved data
await updateNFTCachedData(offers)
} catch (err) {
console.error('Error loading NFT offers:', err)
}
}, [lazyLoadTokenData, lazyLoadMutableData, getNftOffers, reviewNftCachedData, updateNFTCachedData])
// Effect to load NFTs on component mount
useEffect(() => {
console.log('loading nfts for sale')
loadNftOffers()
}, [loadNftOffers])
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// This function generates a Token Card for each token in the wallet.
function generateCards (offers) {
console.log('generateCards() offerData: ', offers)
const tokens = offers
const tokenCards = []
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
const thisTokenCard = (
<TokenCard
appData={appData}
token={thisToken}
handleRefresh={handleRefresh}
key={`${thisToken.tokenId + i}`}
hideBuyBtn
hideSendBtn
/>
)
tokenCards.push(thisTokenCard)
}
return tokenCards
}
return (
<Container>
{!offersAreLoaded && (
<div className='d-flex justify-content-center align-items-center h-100 mb-4'>
<Spinner />
</div>
)}
{offersAreLoaded && offers.length !== 0 && (
<div className='bg-white rounded-1 shadow-sm border mb-4'>
<div className='p-3 border-bottom bg-light rounded-top-4'>
<h5 className='mb-0 fw-bold text-dark'>Tokens For Sale</h5>
</div>
<div
className='p-3'
style={{
height: '800px',
overflowY: 'auto',
maxHeight: '800px'
}}
>
<Row>
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{/** Show spinner info if tokens are loaded but data is not loaded */
offersAreLoaded && !dataAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Data </span>
<Spinner animation='border' />
</div>
)
}
{/** Show spinner info if tokens are loaded but icons are not loaded */
dataAreLoaded && !iconsAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Icons </span>
<Spinner animation='border' />
</div>
)
}
</Col>
</Row>
{!offersAreLoaded && (
<Row className='d-block text-center'>
<Spinner animation='border' variant='primary' style={{ maegin: '0 auto' }} />
</Row>
)}
<Row>
{offersAreLoaded && generateCards(offers)}
</Row>
</div>
</div>
)}
</Container>
)
}
export default NFTForSale
@@ -1,32 +1,38 @@
/**
* Component for read nostr information kind 0
* Component to read nostr information kind 0 (normal posts)
*
* TODO:
* - Currently feeds are retrieved from only one relay. It should retrieve Kind
* 0 posts from all relays. It should then remove any duplicate entries.
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Button } from 'react-bootstrap'
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import { faUser, faGlobe } from '@fortawesome/free-solid-svg-icons'
import * as nip19 from 'nostr-tools/nip19'
// Local libraries
import config from '../../../../config'
import NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
function ProfileRead (props) {
const { npub } = props
const { bchWalletState } = props.appData
console.log('npub prop', npub)
const { setProfile } = props
const [post, setPost] = useState({})
const [loaded, setLoaded] = useState(false)
const [imageError, setImageError] = useState({ picture: false, banner: false })
useEffect(() => {
const start = () => {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
console.log('pubhex', pubHex)
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
// const pool = RelayPool(config.nostrRelays)
const pool = new RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubHex] })
setLoaded(true)
@@ -39,10 +45,14 @@ function ProfileRead (props) {
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
const profile = JSON.parse(ev.content)
console.log('Profile:', profile)
setPost(profile)
setProfile(profile)
try {
const profile = JSON.parse(ev.content)
console.log('Profile:', profile)
setPost(profile)
setProfile(profile)
} catch (error) {
console.error('Error parsing profile:', error)
}
})
setLoaded(true)
@@ -51,40 +61,101 @@ function ProfileRead (props) {
if (!loaded && npub) {
start()
}
}, [bchWalletState, loaded, setProfile, npub])
}, [loaded, setProfile, npub])
const handleImageError = (type) => {
setImageError(prev => ({ ...prev, [type]: true }))
}
const handleImageLoad = (type) => {
setImageError(prev => ({ ...prev, [type]: false }))
}
return (
<Container>
<div className='d-flex flex-column flex-md-row align-items-center gap-4 mb-5 p-3 p-md-5 bg-light rounded-4 shadow-sm'>
<div style={{ width: '100px', height: '100px' }} className='mb-3 mb-md-0'>
<div
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center'
style={{
width: '100%',
height: '100%',
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
<FontAwesomeIcon icon={faUser} size='3x' color='#7c7c7d' />
</div>
{/* Banner Section */}
{post.banner && !imageError.banner && (
<div className='position-relative'>
<img
src={post.banner}
alt='Profile banner'
className='w-100 rounded-4 shadow-sm'
style={{ height: '200px', objectFit: 'cover' }}
onError={() => handleImageError('banner')}
onLoad={() => handleImageLoad('banner')}
/>
</div>
)}
<div className='d-flex flex-column flex-md-row align-items-center gap-4 mb-5 p-3 p-md-5 bg-light rounded-4 shadow-sm'>
{/* Profile Picture */}
<div style={{ width: '120px', height: '120px' }} className='mb-3 mb-md-0'>
{post.picture && !imageError.picture
? (
<img
src={post.picture}
alt='Profile'
className='rounded-circle shadow w-100 h-100'
style={{ objectFit: 'cover' }}
onError={() => handleImageError('picture')}
onLoad={() => handleImageLoad('picture')}
/>
)
: (
<div
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center'
style={{
width: '100%',
height: '100%',
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
<FontAwesomeIcon icon={faUser} size='3x' color='#7c7c7d' />
</div>
)}
</div>
{/* Profile Information */}
<div className='flex-grow-1 text-center text-md-start mb-3 mb-md-0 d-flex flex-column align-items-center align-items-md-start'>
<h3 className='mb-2 fw-bold'>{post.name}</h3>
<h3 className='mb-2 fw-bold'>{post.name || 'username'}</h3>
<div className='text-muted small mb-3 d-flex align-items-center flex-column flex-md-row'>
<span className='text-truncate me-2 mb-2 mb-md-0'>
<span className='d-md-none'>
{`${bchWalletState.nostrKeyPair.npub.slice(0, 8)}...${bchWalletState.nostrKeyPair.npub.slice(-5)}`}
{`${npub?.slice(0, 8)}...${npub?.slice(-5)}`}
</span>
<span className='d-none d-md-inline'>
{bchWalletState.nostrKeyPair.npub}
{npub}
</span>
</span>
<CopyOnClick walletProp='npub' appData={props.appData} value={bchWalletState.nostrKeyPair.npub} />
</div>
<div className='fs-6 text-secondary'>
<NostrFormat content={post.about} />
<CopyOnClick walletProp='npub' appData={props.appData} value={npub} />
</div>
{/* About Section */}
{post.about && (
<div className='fs-6 text-secondary'>
<NostrFormat content={post.about} />
</div>
)}
{/* Website Link */}
{post.website && (
<div className='mb-3 d-flex align-items-center gap-2'>
<FontAwesomeIcon icon={faGlobe} className='text-muted' size='sm' />
<a
href={post.website.startsWith('http') ? post.website : `https://${post.website}`}
target='_blank'
rel='noopener noreferrer'
className='text-decoration-none text-muted small'
style={{ wordBreak: 'break-all' }}
>
{post.website}
</a>
</div>
)}
</div>
{/* Action Buttons */}
<div className='d-flex gap-2 align-items-center flex-wrap justify-content-center'>
<Button
variant='outline-danger'
@@ -6,15 +6,19 @@ import React, { useEffect, useState } from 'react'
import { Container, Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
// Local libraries
import config from '../../../../config'
import NostrFormat from '../nostr-format'
function PublicRead (props) {
const { npub } = props
const { bchWalletState } = props.appData
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
const [profilePictureError, setProfilePictureError] = useState(false)
useEffect(() => {
// Get Last post from a author
@@ -22,9 +26,8 @@ function PublicRead (props) {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
console.log('pubhex', pubHex)
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
const pool = RelayPool(config.nostrRelays)
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] })
setLoaded(true)
@@ -47,6 +50,14 @@ function PublicRead (props) {
}
}, [bchWalletState, loaded, npub])
const handleProfilePictureError = () => {
setProfilePictureError(true)
}
const handleProfilePictureLoad = () => {
setProfilePictureError(false)
}
return (
<Container className='mt-4 mb-5'>
{/* Nostr Feed Section */}
@@ -74,12 +85,25 @@ function PublicRead (props) {
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' />
{props.profile?.picture && !profilePictureError
? (
<img
src={props.profile.picture}
alt='Profile'
className='rounded-circle w-100 h-100'
style={{ objectFit: 'cover' }}
onError={handleProfilePictureError}
onLoad={handleProfilePictureLoad}
/>
)
: (
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' />
)}
</div>
<div className='flex-grow-1'>
<div className='fw-bold mb-1'>{props.profile?.name}</div>
<small className='text-muted'>
{`${bchWalletState.nostrKeyPair.npub.slice(0, 8)}...${bchWalletState.nostrKeyPair.npub.slice(-5)}`}
{`${npub.slice(0, 8)}...${npub.slice(-5)}`}
</small>
</div>
<small className='text-muted'>
@@ -91,7 +91,7 @@ function SlpTokensDisplay (props) {
const getTokens = async () => {
try {
const { nostrKeyPair } = bchWalletState
if (nostrKeyPair.npub === npub) {
if (nostrKeyPair?.npub === npub) {
setTokens(bchWalletState.slpTokens)
setLoaded(true)
return
@@ -13,11 +13,11 @@ import { Button, Modal, Container, Row, Col } from 'react-bootstrap'
// returned with a link. Otherwise the original string is returned.
function linkIfUrl (url) {
// Convert the URL into a link if it contains 'http'
if (url.includes('http')) {
if (url?.includes('http')) {
url = (<a href={url} target='_blank' rel='noreferrer'>{url}</a>)
//
} else if (url.includes('ipfs://')) {
} else if (url?.includes('ipfs://')) {
// Convert to a Filecoin link if its an IPFS reference.
const cid = url.substring(7)
+107 -103
View File
@@ -15,8 +15,7 @@ import Logo from './psf-logo.png'
function NavMenu (props) {
// Get the current path
const { currentPath, bchWalletState } = props.appData
const { currentPath, bchWalletState, isSingleView } = props.appData
// Navbar state
const [expanded, setExpanded] = useState(false)
@@ -25,125 +24,130 @@ function NavMenu (props) {
// Collapse the navbar
setExpanded(false)
}
const goToHome = () => {
if (isSingleView) {
window.location.href = '/'
}
}
return (
<>
<Navbar expanded={expanded} onToggle={setExpanded} expand='xxxl' bg='dark' variant='dark' style={{ paddingRight: '20px' }}>
<Navbar.Brand href='#home' style={{ paddingLeft: '20px' }}>
<Navbar.Brand href='#home' style={{ paddingLeft: '20px' }} onClick={goToHome}>
<Image src={Logo} thumbnail width='50' />{' '}
DEX
</Navbar.Brand>
<Navbar.Toggle aria-controls='responsive-navbar-nav' />
<Navbar.Collapse id='responsive-navbar-nav'>
<Nav className='mr-auto'>
{!isSingleView && <Navbar.Toggle aria-controls='responsive-navbar-nav' />}
{!isSingleView && (
<Navbar.Collapse id='responsive-navbar-nav'>
<Nav className='mr-auto'>
<NavLink
className={(currentPath === '/nfts-for-sale' || currentPath === '/') ? 'nav-link-active' : 'nav-link-inactive'}
to='/nfts-for-sale'
onClick={handleClickEvent}
>
NFTs
</NavLink>
<NavLink
className={(currentPath === '/nfts-for-sale' || currentPath === '/') ? 'nav-link-active' : 'nav-link-inactive'}
to='/nfts-for-sale'
onClick={handleClickEvent}
>
NFTs
</NavLink>
<NavLink
className={currentPath === '/offers' ? 'nav-link-active' : 'nav-link-inactive'}
to='/offers'
onClick={handleClickEvent}
>
Fungible Tokens
</NavLink>
<NavLink
className={currentPath === '/offers' ? 'nav-link-active' : 'nav-link-inactive'}
to='/offers'
onClick={handleClickEvent}
>
Fungible Tokens
</NavLink>
<hr />
<hr />
<NavLink
className={currentPath === '/global-feed' ? 'nav-link-active' : 'nav-link-inactive'}
to='/feeds'
onClick={handleClickEvent}
>
Feeds
</NavLink>
<NavLink
className={currentPath === '/nostr-post' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-post'
onClick={handleClickEvent}
>
Nostr Post
</NavLink>
<NavLink
className={currentPath === '/profile' ? 'nav-link-active' : 'nav-link-inactive'}
to={`/profile/${bchWalletState?.nostrKeyPair?.npub}`}
onClick={handleClickEvent}
>
Nostr Profile
</NavLink>
<NavLink
className={currentPath === '/content-creators' ? 'nav-link-active' : 'nav-link-inactive'}
to='/content-creators'
onClick={handleClickEvent}
>
Content Creators
</NavLink>
<NavLink
className={currentPath === '/global-feed' ? 'nav-link-active' : 'nav-link-inactive'}
to='/global-feed'
onClick={handleClickEvent}
>
Global Feed
</NavLink>
<NavLink
className={currentPath === '/nostr-post' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-post'
onClick={handleClickEvent}
>
Nostr Post
</NavLink>
<NavLink
className={currentPath === '/profile' ? 'nav-link-active' : 'nav-link-inactive'}
to={`/profile/${bchWalletState?.nostrKeyPair?.npub}`}
onClick={handleClickEvent}
>
Nostr Profile
</NavLink>
<NavLink
className={currentPath === '/content-creators' ? 'nav-link-active' : 'nav-link-inactive'}
to='/content-creators'
onClick={handleClickEvent}
>
Content Creators
</NavLink>
<hr />
<hr />
<NavLink
className={(currentPath === '/bch') ? 'nav-link-active' : 'nav-link-inactive'}
to='/bch'
onClick={handleClickEvent}
>
BCH
</NavLink>
<NavLink
className={(currentPath === '/bch') ? 'nav-link-active' : 'nav-link-inactive'}
to='/bch'
onClick={handleClickEvent}
>
BCH
</NavLink>
<NavLink
className={currentPath === '/slp-tokens' ? 'nav-link-active' : 'nav-link-inactive'}
to='/slp-tokens'
onClick={handleClickEvent}
>
Tokens
</NavLink>
<NavLink
className={currentPath === '/slp-tokens' ? 'nav-link-active' : 'nav-link-inactive'}
to='/slp-tokens'
onClick={handleClickEvent}
>
Tokens
</NavLink>
<NavLink
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
to='/wallet'
onClick={handleClickEvent}
>
Wallet
</NavLink>
<NavLink
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
to='/wallet'
onClick={handleClickEvent}
<NavLink
className={(currentPath === '/balance') ? 'nav-link-active' : 'nav-link-inactive'}
to='/balance'
onClick={handleClickEvent}
>
Check Balance
</NavLink>
>
Wallet
</NavLink>
<NavLink
className={(currentPath === '/sweep') ? 'nav-link-active' : 'nav-link-inactive'}
to='/sweep'
onClick={handleClickEvent}
>
Sweep
</NavLink>
<NavLink
className={(currentPath === '/sign') ? 'nav-link-active' : 'nav-link-inactive'}
to='/sign'
onClick={handleClickEvent}
>
Sign
</NavLink>
<NavLink
className={currentPath === '/configuration' ? 'nav-link-active' : 'nav-link-inactive'}
to='/configuration'
onClick={handleClickEvent}
>
Configuration
</NavLink>
<NavLink
className={(currentPath === '/balance') ? 'nav-link-active' : 'nav-link-inactive'}
to='/balance'
onClick={handleClickEvent}
>
Check Balance
</NavLink>
<NavLink
className={(currentPath === '/sweep') ? 'nav-link-active' : 'nav-link-inactive'}
to='/sweep'
onClick={handleClickEvent}
>
Sweep
</NavLink>
<NavLink
className={(currentPath === '/sign') ? 'nav-link-active' : 'nav-link-inactive'}
to='/sign'
onClick={handleClickEvent}
>
Sign
</NavLink>
<NavLink
className={currentPath === '/configuration' ? 'nav-link-active' : 'nav-link-inactive'}
to='/configuration'
onClick={handleClickEvent}
>
Configuration
</NavLink>
</Nav>
</Navbar.Collapse>
</Nav>
</Navbar.Collapse>
)}
</Navbar>
</>
)
+7 -2
View File
@@ -15,10 +15,15 @@ const config = {
radicleUrl: 'https://app.radicle.network/seeds/maple.radicle.garden/rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy/remotes/hyyycncbn9qzqmobnhjq9rry6t4mbjiadzjoyhaknzxjcz3cxkpfpc',
dexServer: 'https://dex-api.fullstack.cash',
//dexServer: 'http://localhost:5700',
// dexServer: 'http://localhost:5700',
nostrTopic: 'bch-dex-test-topic-02',
nostrRelay: 'wss://nostr-relay.psfoundation.info'
nostrRelay: 'wss://nostr-relay.psfoundation.info',
nostrRelays: [
'wss://nostr-relay.psfoundation.info',
'wss://nos.lol',
'wss://relay.damus.io'
]
}
+10 -3
View File
@@ -14,7 +14,8 @@ function useAppState () {
defaultValue: {
serverUrl: 'https://free-bch.fullstack.cash' // Default server
},
nftData: {}
nftData: {},
lastFeedTab: 'feed'
})
console.log('lsState: ', lsState)
@@ -26,6 +27,7 @@ function useAppState () {
const [servers, setServers] = useState([])
const [dexLib, setDexLib] = useState(false)
const [nostr, setNostr] = useState(false)
const [lastFeedTab, setLastFeedTab] = useState(lsState.lastFeedTab || 'feed')
// Startup state management
const [asyncInitStarted, setAsyncInitStarted] = useState(false)
@@ -37,6 +39,7 @@ function useAppState () {
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [denyClose, setDenyClose] = useState(false)
const [isSingleView, setIsSingleView] = useState(false)
// NFTs for sale stored data to improve performance
const [nftForSaleCacheData, setNftForSaleCacheData] = useState(lsState.nftData || {})
@@ -53,7 +56,7 @@ function useAppState () {
// console.log('lsState: ', lsState)
const removeLocalStorageItem = removeItem
const updateLocalStorage = (lsObj) => {
// console.log(`updateLocalStorage() input: ${JSON.stringify(lsObj, null, 2)}`)
console.log(`updateLocalStorage() input: ${JSON.stringify(lsObj, null, 2)}`)
// Progressively overwrite the LocalStorage state.
const newObj = Object.assign({}, lsState, lsObj)
@@ -149,7 +152,11 @@ function useAppState () {
setNostr,
nftForSaleCacheData,
setNftForSaleCacheData,
updateNFTCachedData
updateNFTCachedData,
lastFeedTab,
setLastFeedTab,
isSingleView,
setIsSingleView
}
}
+41
View File
@@ -283,6 +283,47 @@ class AsyncLoad {
npub
}
}
// Initialize the BCH wallet without initialize() promise
async initStarterWallet (restURL, mnemonic, appData) {
try {
const options = {
interface: 'consumer-api',
restURL,
noUpdate: true
}
let wallet
if (mnemonic) {
// Load the wallet from the mnemonic, if it's available from local storage.
wallet = new this.BchWallet(mnemonic, options)
} else {
// Generate a new mnemonic and wallet.
wallet = new this.BchWallet(null, options)
}
await wallet.walletInfoPromise
// Get Nostr key pair from WIF
const nostrKeyPair = this.nostrKeyPairFromWIF(wallet.walletInfo.privateKey)
const walletInfo = wallet.walletInfo
walletInfo.nostrKeyPair = nostrKeyPair
// Update the state of the wallet.
appData.updateBchWalletState({ walletObj: walletInfo, appData })
// Save the mnemonic to local storage.
if (!mnemonic) {
const newMnemonic = wallet.walletInfo.mnemonic
appData.updateLocalStorage({ mnemonic: newMnemonic })
}
this.wallet = wallet
return wallet
} catch (error) {
console.error('Error initStarterWallet: ', error)
throw error
}
}
}
function sleep (ms) {