Compare commits

..
34 Commits
Author SHA1 Message Date
Chris Troutner faec078403 Merge pull request #54 from Permissionless-Software-Foundation/dh-nostr-library
feat(nostr): Created Nostr service library
2025-08-21 07:19:00 -07:00
Daniel Gonzalez 416941089b feat(nostr): Created Nostr service library 2025-08-20 20:39:05 -04:00
Chris Troutner f4c47d4ce3 Merge pull request #53 from Permissionless-Software-Foundation/dh-profile-todos
feat(nostr): Completed profile feed TODOs
2025-08-15 11:58:43 -07:00
Chris Troutner 121f1001e1 Merge branch 'master' into dh-profile-todos 2025-08-15 10:36:20 -07:00
Chris Troutner e8f077dba2 Merge pull request #52 from Permissionless-Software-Foundation/dh-feeds-todos
feat(nostr): Completed TODOs on feeds page
2025-08-15 10:35:37 -07:00
Chris Troutner 3fbbdbc6a7 minor edits 2025-08-15 10:33:47 -07:00
Daniel Gonzalez 43013bd464 feat(nostr): Completed profile feed TODOs 2025-08-15 12:55:34 -04:00
Daniel Gonzalez 391ddb62de feat(nostr): Completed TODOs on feeds page 2025-08-14 11:39:31 -04:00
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
22 changed files with 836 additions and 365 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",
+7 -1
View File
@@ -14,7 +14,10 @@ import NavMenu from './components/nav-menu'
import useAppState from './hooks/state'
import { UninitializedView, InitializedView } from './components/starter-views'
const sigleViewPaths = ['/profile']
// 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.
@@ -45,6 +48,9 @@ function App (props) {
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
@@ -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
@@ -1,13 +1,24 @@
/*
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'
import { RelayPool } from 'nostr'
// Global variables and constants
const SERVER = config.dexServer
@@ -19,64 +30,16 @@ function ContentCreators (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 = []
const { nostrKeyPair } = appData.bchWalletState
const psf = 'wss://nostr-relay.psfoundation.info'
const { nostrKeyPair } = appData.bchWalletState
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)
const pubKeyHex = nostrKeyPair.pubHex
const list = await appData.nostrQueries.getFollowList(pubKeyHex)
setFollowList(list)
}, [appData])
const loadProfile = useCallback(async (pubKey) => {
return new Promise((resolve) => {
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubKey] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
resolve(false)
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
// console.log('profile', profile)
resolve(profile)
} catch (error) {
resolve(false)
}
})
})
}, [])
useEffect(() => {
const loadCreators = async () => {
try {
@@ -89,7 +52,7 @@ function ContentCreators (props) {
for (let i = 0; i < creators.length; i++) {
try {
const creator = creators[i]
const profile = await loadProfile(creator.pubkey)
const profile = await appData.nostrQueries.getProfile(creator.pubkey)
// the folowing lines should re-render the ContentCard
setCreators(prevCreators => {
@@ -110,7 +73,7 @@ function ContentCreators (props) {
loadCreators()
getFollowList()
}
}, [loaded, followList, getFollowList, loadProfile])
}, [loaded, followList, getFollowList, appData])
const filteredCreators = useCallback(() => {
if (!creators || creators.length === 0) {
@@ -1,6 +1,7 @@
/**
* Component for displaying nostr posts
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { Card, Spinner } from 'react-bootstrap'
@@ -8,11 +9,14 @@ 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'
import config from '../../../../config/index.js'
// Local libraries
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import NostrFormat from '../nostr-format'
function FeedCard (props) {
const { post, appData, profiles } = props
@@ -21,61 +25,36 @@ function FeedCard (props) {
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] })
})
// Get all post likes events
const likesArr = await appData.nostrQueries.getPostLikes(post.id)
const likesCount = likesArr.length
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)
// Verify if the users pubkey is in the likes array
const userLikedPost = likesArr.find(val => { return val.pubkey === userPubKey })
setLikesCount(likesCount)
setIsLiked(userLikedPost)
setLikesFetched(true)
} catch (error) {
console.warn(error)
}
}, [])
}, [appData])
const handleProfilePictureError = () => {
setProfilePictureError(true)
}
const handleProfilePictureLoad = () => {
setProfilePictureError(false)
}
// Get npub from pubkey
useEffect(() => {
const npub = nip19.npubEncode(post.pubkey)
@@ -94,13 +73,6 @@ function FeedCard (props) {
}
}, [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)
@@ -116,9 +88,6 @@ function FeedCard (props) {
// 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 = '-' }
@@ -128,27 +97,32 @@ function FeedCard (props) {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', post.id, psf],
['p', psf, nostrKeyPair.pubHex]
['e', post.id],
['p', nostrKeyPair.pubHex]
],
content
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
config.nostrRelays.map(async (relayUrl) => {
try {
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
console.log(`connected to ${relay.url}`)
// 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)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Close the connection to the relay.
relay.close()
// Close the connection to the relay.
relay.close()
} catch (err) {
console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`)
}
})
await handleLikes(post, nostrKeyPair.pubHex)
setLikesFetched(true)
@@ -158,6 +132,11 @@ function FeedCard (props) {
}
}
const goToProfile = () => {
const profileUrl = `${window.location.origin}/profile/${npub}#single-view`
window.open(profileUrl, '_blank')
}
return (
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'>
@@ -170,13 +149,27 @@ function FeedCard (props) {
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' />
{profile?.picture && !profilePictureError
? (
<img
src={profile.picture}
alt='Profile'
className='rounded-circle w-100 h-100'
style={{ objectFit: 'cover', cursor: 'pointer' }}
onError={handleProfilePictureError}
onLoad={handleProfilePictureLoad}
onClick={goToProfile}
/>
)
: (
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' style={{ cursor: 'pointer' }} onClick={goToProfile} />
)}
</div>
<div className='flex-grow-1'>
<div className='fw-bold mb-1'>
{profile && profile.name && <span>{profile.name}</span>}
{profile && profile.name && <span style={{ cursor: 'pointer' }} onClick={goToProfile}>{profile.name}</span>}
{!profile?.name && (
<span>
<span style={{ cursor: 'pointer' }} onClick={goToProfile}>
{post.pubkey.slice(0, 8) + '...'}
{!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />}
</span>
@@ -188,14 +181,20 @@ function FeedCard (props) {
title='Copy to clipboard'
style={{
cursor: 'pointer',
transform: isClicked ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease',
display: 'inline-block'
display: 'inline-block',
marginRight: '5px'
}}
onClick={() => copyToClipboard(npub)}
onClick={goToProfile}
>
{getShortNpub(npub)}
</span>
<CopyOnClick
walletProp='npub'
appData={props.appData}
value={npub}
/>
</small>
)}
</div>
@@ -4,9 +4,10 @@
// 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) {
@@ -19,9 +20,8 @@ function Following (props) {
const followingList = await new Promise((resolve, reject) => {
let list = []
const { nostrKeyPair } = appData.bchWalletState
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
const pool = RelayPool(config.nostrRelays)
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [3], authors: [nostrKeyPair.pubHex] })
})
+24 -68
View File
@@ -1,98 +1,54 @@
/*
Component for reading the nostr feeds.
Component for reading the nostr feeds.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect } from 'react'
import { Container, Nav, Tab, Spinner } from 'react-bootstrap'
// Local libraries
import Feed from './feed'
import Following from './following'
import { RelayPool } from 'nostr'
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((pubkey) => {
// no fetch profile again if it exist
setProfiles(currentProfiles => {
if (currentProfiles[pubkey]) {
return currentProfiles
}
return currentProfiles
})
// Load data on component mount.
useEffect(() => {
const loadData = async () => {
// Get feeds
const feeds = await appData.nostrQueries.getGlobalFeeds()
setPosts(feeds)
setLoaded(true)
const psf = 'wss://nostr-relay.psfoundation.info'
const loadedProfiles = [] // fetched profiles ( this will be used for prevent load the same profile multiple times.)
// Map feeds and get feed owner profile.
for (let i = 0; i < feeds.length; i++) {
const pubKey = feeds[i].pubkey
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubkey] })
})
const exist = loadedProfiles.find((val) => { return val === pubKey })
if (exist) { continue }
// Fech profile.
const profile = await appData.nostrQueries.getProfile(pubKey)
loadedProfiles.push(pubKey) // mark as loaded
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) {
// skip error
}
})
pool.on('event', (relay, subId, ev) => {
try {
// update profiles data
const profile = JSON.parse(ev.content)
// Update profile state
setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles }
newProfiles[pubkey] = profile
newProfiles[pubKey] = profile
return newProfiles
})
} catch (error) {
// skip error
}
})
}, [])
// 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 => {
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()
loadData()
}
}, [bchWalletState, loaded, fetchProfile])
}, [loaded, appData])
const onChangeTab = (tab) => {
setActiveTab(tab)
@@ -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'
@@ -9,10 +9,13 @@ 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 { appData } = props
const { bchWalletState } = appData
const [accordionKey, setAccordionKey] = useState(null)
const [onFetch, setOnFetch] = useState(false)
const [formLoaded, setFormLoaded] = useState(false)
@@ -31,40 +34,19 @@ function ProfilePost (props) {
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) {
}
})
const getLastProfilePost = async () => {
const { nostrKeyPair } = appData.bchWalletState
const lastProfile = await appData.nostrQueries.getProfile(nostrKeyPair.pubHex)
if (lastProfile) {
setFormData(lastProfile)
}
setFormLoaded(true)
}
if (!formLoaded) {
getLastPost()
getLastProfilePost()
}
}, [bchWalletState, formLoaded])
}, [appData, formLoaded])
const handleInputChange = (e) => {
const { name, value } = e.target
@@ -90,7 +72,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)
@@ -107,16 +89,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()
setSuccessMsg('Post successfully published!')
setOnFetch(false)
} catch (error) {
@@ -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()
@@ -17,9 +17,10 @@ function Profile (props) {
return (
<>
<Container>
<ProfileRead {...props} setProfile={setProfile} npub={npub} />
<ProfileRead {...props} onProfileRead={setProfile} npub={npub} />
<PublicRead {...props} profile={profile} npub={npub} />
<SlpTokensDisplay {...props} npub={npub} />
<NFTForSale {...props} npub={npub} />
</Container>
</>
)
@@ -0,0 +1,327 @@
/**
* 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 [startAsync, setStartAsync] = 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 = () => {
console.log('handle refresh')
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(() => {
// Prevent to load data twice
if (!startAsync) {
console.log('loading nfts for sale')
loadNftOffers()
setStartAsync(true)
}
}, [loadNftOffers, startAsync])
// 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,51 +1,36 @@
/**
* Component for read nostr information kind 0
* Component to read nostr information kind 0 (normal posts)
*
*/
// 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, faGlobe } 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 NostrFormat from '../nostr-format'
function ProfileRead (props) {
const { npub } = props
const { setProfile } = props
const [post, setPost] = useState({})
const { npub, appData } = props
const { onProfileRead } = props
const [profile, setProfile] = useState({})
const [loaded, setLoaded] = useState(false)
const [imageError, setImageError] = useState({ picture: false, banner: false })
useEffect(() => {
const start = () => {
const start = async () => {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubHex] })
setLoaded(true)
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
try {
const profile = JSON.parse(ev.content)
console.log('Profile:', profile)
setPost(profile)
setProfile(profile)
} catch (error) {
console.error('Error parsing profile:', error)
}
})
const profile = await appData.nostrQueries.getProfile(pubHex)
if (profile) {
onProfileRead(profile)
setProfile(profile)
}
setLoaded(true)
}
@@ -53,7 +38,7 @@ function ProfileRead (props) {
if (!loaded && npub) {
start()
}
}, [loaded, setProfile, npub])
}, [loaded, onProfileRead, npub, appData])
const handleImageError = (type) => {
setImageError(prev => ({ ...prev, [type]: true }))
@@ -66,10 +51,10 @@ function ProfileRead (props) {
return (
<Container>
{/* Banner Section */}
{post.banner && !imageError.banner && (
{profile.banner && !imageError.banner && (
<div className='position-relative'>
<img
src={post.banner}
src={profile.banner}
alt='Profile banner'
className='w-100 rounded-4 shadow-sm'
style={{ height: '200px', objectFit: 'cover' }}
@@ -82,10 +67,10 @@ function ProfileRead (props) {
<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
{profile.picture && !imageError.picture
? (
<img
src={post.picture}
src={profile.picture}
alt='Profile'
className='rounded-circle shadow w-100 h-100'
style={{ objectFit: 'cover' }}
@@ -109,7 +94,7 @@ function ProfileRead (props) {
{/* 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 || 'username'}</h3>
<h3 className='mb-2 fw-bold'>{profile.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'>
@@ -124,24 +109,24 @@ function ProfileRead (props) {
</div>
{/* About Section */}
{post.about && (
{profile.about && (
<div className='fs-6 text-secondary'>
<NostrFormat content={post.about} />
<NostrFormat content={profile.about} />
</div>
)}
{/* Website Link */}
{post.website && (
{profile.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}`}
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target='_blank'
rel='noopener noreferrer'
className='text-decoration-none text-muted small'
style={{ wordBreak: 'break-all' }}
>
{post.website}
{profile.website}
</a>
</div>
)}
@@ -6,46 +6,38 @@ 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'
// Local libraries
import NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
function PublicRead (props) {
const { npub } = props
const { bchWalletState } = props.appData
const { npub, appData } = props
const { bchWalletState } = appData
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
const [profilePictureError, setProfilePictureError] = useState(false)
useEffect(() => {
// Get Last post from a author
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])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] })
setLoaded(true)
})
pool.on('eose', relay => {
console.log('Closing Relay')
setLoaded(true)
relay.close()
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
setPosts(currentPosts => [...currentPosts, ev])
})
const start = async () => {
const pubKeyHex = appData.nostrQueries.npubToHex(npub)
const feeds = await appData.nostrQueries.getUserFeeds(pubKeyHex)
setPosts(feeds)
setLoaded(true)
}
if (!loaded && npub) {
start()
}
}, [bchWalletState, loaded, npub])
}, [bchWalletState, loaded, npub, appData])
const handleProfilePictureError = () => {
setProfilePictureError(true)
}
const handleProfilePictureLoad = () => {
setProfilePictureError(false)
}
return (
<Container className='mt-4 mb-5'>
@@ -74,7 +66,20 @@ 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>
@@ -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)
+6 -1
View File
@@ -18,7 +18,12 @@ const config = {
// 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'
]
}
+3 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
// import { useQueryParam, StringParam } from 'use-query-params'
import useLocalStorageState from 'use-local-storage-state'
import AppUtil from '../util'
import NostrQueries from '../services/nostr-queries'
import { useLocation } from 'react-router-dom'
@@ -156,7 +157,8 @@ function useAppState () {
lastFeedTab,
setLastFeedTab,
isSingleView,
setIsSingleView
setIsSingleView,
nostrQueries: new NostrQueries()
}
}
+235
View File
@@ -0,0 +1,235 @@
/**
* Nostr class for query into relay pools
*
*/
import config from '../config'
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
export default class NostrQueries {
constructor () {
this.relays = config.nostrRelays
}
npubToHex (npub) {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
return pubHex
}
// 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.
async getProfile (pubHex) {
try {
for (let i = 0; i < this.relays.length; i++) {
const profile = await new Promise((resolve) => {
const relay = this.relays[i]
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([relay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubHex] })
})
pool.on('eose', 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 ${pubHex} at ${relay.url}`)
resolve(profile)
} catch (error) {
resolve(false)
}
relay.close()
})
})
// Stop looking for profile if found
if (profile) {
return profile
}
}
} catch (error) {
console.warn(error)
}
}
// Get Feeds by user pubkey
async getUserFeeds (pubHex) {
try {
let feeds = await new Promise((resolve) => {
let list = []
let closedRelays = 0
const pool = RelayPool(this.relays)
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] })
})
pool.on('eose', relay => {
relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === config.nostrRelays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
list = [...list, ev]
})
})
// Remove duplicated feeds
feeds = feeds.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
// Sort from newest to oldest
feeds.sort((a, b) => b.created_at - a.created_at)
return feeds
} catch (error) {
console.warn(error)
}
}
// Get global feeds
async getGlobalFeeds () {
try {
let feeds = await new Promise((resolve, reject) => {
let list = []
let closedRelays = 0
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 => {
relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === config.nostrRelays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
// console.log('post retrieved from ', relay.url, ev.sig)
list = [...list, ev]
})
})
// Remove duplicated feeds
feeds = feeds.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
// Sort from newest to oldest
feeds.sort((a, b) => b.created_at - a.created_at)
// console.log('feeds', feeds)
return feeds
} catch (error) {
console.warn(error)
}
}
// Get follow list by pubkey
async getFollowList (pubHex) {
return new Promise((resolve, reject) => {
let list = []
let closedRelays = 0
const pool = RelayPool(config.nostrRelays)
// const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [3], authors: [pubHex] })
})
pool.on('eose', relay => {
relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === this.relays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
// console.log('Received event:', ev)
// Merge list received from all relays
list = [...list, ...ev.tags]
})
})
}
// Get event likes
async getPostLikes (postId) {
try {
let likesRes = await new Promise((resolve) => {
const likes = []
let closedRelays = 0
const pool = RelayPool(this.relays)
pool.on('open', relay => {
relay.subscribe('subid', { kinds: [7], '#e': [postId] })
})
pool.on('eose', relay => {
relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === this.relays.length) {
resolve(likes)
}
})
pool.on('event', (relay, subId, ev) => {
try {
// Count likes
if (ev.content === '+' || ev.content === '-') {
likes.push(ev)
}
} catch (error) {
// skip error
}
})
})
// Remove duplicated events
likesRes = likesRes.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
// Get likes
const likesArr = likesRes.filter((val, i, list) => {
return val.content === '+'
})
// Get dislikes
const dislikesArr = likesRes.filter((val, i, list) => {
return val.content === '-'
})
// For every user dislike remove a user like from the array
for (let i = 0; i < dislikesArr.length; i++) {
const disLike = dislikesArr[i]
const likeExist = likesArr.findIndex(val => val.pubkey === disLike.pubkey)
if (likeExist >= 0) likesArr.splice(likeExist, 1)
}
// Return array of likes.
return likesArr
} catch (error) {
console.warn(error)
}
}
}