Compare commits

...
44 Commits
Author SHA1 Message Date
Chris Troutner 8607ce4f15 Merge pull request #55 from Permissionless-Software-Foundation/dh-relay-selection
feat(nostr): Added relay selection to Configuration page
2025-08-23 11:48:02 -07:00
Daniel Gonzalez e18addff30 feat(nostr): Added relay selection to Configuration page 2025-08-23 14:24:24 -04:00
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
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
29 changed files with 1662 additions and 467 deletions
+4 -4
View File
@@ -15,7 +15,7 @@
"@fortawesome/react-fontawesome": "0.2.2", "@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "^1.8.0", "@noble/hashes": "^1.8.0",
"axios": "0.27.2", "axios": "0.27.2",
"bch-dex-lib": "2.0.3", "bch-dex-lib": "2.2.0",
"bch-message-lib": "2.2.1", "bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4", "bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1", "bch-token-sweep": "2.2.1",
@@ -10043,9 +10043,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/bch-dex-lib": { "node_modules/bch-dex-lib": {
"version": "2.0.3", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/bch-dex-lib/-/bch-dex-lib-2.0.3.tgz", "resolved": "https://registry.npmjs.org/bch-dex-lib/-/bch-dex-lib-2.2.0.tgz",
"integrity": "sha512-aPZIlDcRziZBRKRhEuAv6G/skFjZ28+sE30fkoUY8isUIAe3C0YS7yQMoOmGSA6N46/XuhRbfQi0SRQJfrASpA==", "integrity": "sha512-SORqn4qNmbxfL2SbbapOpbcCB+FtqasR+GT0XRifdkXFjA3aDL0gwMsHjh8IzLZnKtPYSBcfgzKQFxEsXCJNug==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@chris.troutner/retry-queue": "1.0.10", "@chris.troutner/retry-queue": "1.0.10",
+1 -1
View File
@@ -9,7 +9,7 @@
"@fortawesome/react-fontawesome": "0.2.2", "@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "^1.8.0", "@noble/hashes": "^1.8.0",
"axios": "0.27.2", "axios": "0.27.2",
"bch-dex-lib": "2.0.3", "bch-dex-lib": "2.2.0",
"bch-message-lib": "2.2.1", "bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4", "bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1", "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 useAppState from './hooks/state'
import { UninitializedView, InitializedView } from './components/starter-views' 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) { function App (props) {
// Load all the app state into a single object that can be passed to child // Load all the app state into a single object that can be passed to child
// components. // 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. */ /** Load all required data before component start. */
useEffect(() => { useEffect(() => {
async function asyncEffect () { 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 { try {
// Instantiate the async load object. // Instantiate the async load object.
const asyncLoad = new AsyncLoad() const asyncLoad = new AsyncLoad()
@@ -112,7 +151,7 @@ function App (props) {
} }
} }
asyncEffect() asyncEffect()
}, [appData, addToModal]) }, [appData, addToModal, isSignleView])
return ( return (
<> <>
@@ -6,6 +6,7 @@
// Global npm libraries // Global npm libraries
import React from 'react' import React from 'react'
import ServerSelectView from './select-server-view' import ServerSelectView from './select-server-view'
import RelaySelectionView from './relay-selection-view'
function ConfigurationView (props) { function ConfigurationView (props) {
const { appData } = props const { appData } = props
@@ -13,6 +14,7 @@ function ConfigurationView (props) {
return ( return (
<> <>
<ServerSelectView appData={appData} /> <ServerSelectView appData={appData} />
<RelaySelectionView appData={appData} />
</> </>
) )
} }
@@ -0,0 +1,333 @@
/*
This component is a View that allows the user to manage Nostr relay settings.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Row, Col, Form, Card, Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faShareAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
function RelaySelectionView (props) {
const { appData } = props
const { relaysData, updateRelaysData, restoreRelaysData } = appData
console.log('relaysData', relaysData)
const [newRelayAddress, setNewRelayAddress] = useState('')
const addRelay = (newRelay) => {
const exist = relaysData.find(relay => relay.address === newRelay)
if (!exist) {
relaysData.push({ address: newRelay, read: true, write: true })
updateRelaysData(relaysData)
}
}
// Delete relay from relaysData array
const deleteRelay = (relayToDelete) => {
const index = relaysData.findIndex(relay => relay.address === relayToDelete.address)
if (index !== -1) {
relaysData.splice(index, 1)
updateRelaysData(relaysData)
}
}
const handleReadToggle = (relayToToggleRead) => {
const index = relaysData.findIndex(relay => relay.address === relayToToggleRead.address)
if (index !== -1) {
relaysData[index].read = !relaysData[index].read
updateRelaysData(relaysData)
}
}
const handleWriteToggle = (relayToToggleWrite) => {
const index = relaysData.findIndex(relay => relay.address === relayToToggleWrite.address)
if (index !== -1) {
relaysData[index].write = !relaysData[index].write
updateRelaysData(relaysData)
}
}
return (
<>
<Card className='m-3'>
<Card.Body>
<Row>
<Col style={{ textAlign: 'center' }}>
<h2>Nostr Relay Configuration</h2>
<p>
Manage your Nostr relay connections. Configure read and write permissions for each relay.
</p>
</Col>
</Row>
<hr />
<Row className='mb-3'>
<Col className='text-end'>
<Button
onClick={() => restoreRelaysData()}
variant='outline-secondary'
style={{ minWidth: '120px' }}
>
Restore defaults
</Button>
</Col>
</Row>
<Row>
<Col>
<div
className='relay-table-container' style={{
backgroundColor: 'transparent',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
>
<div
className='relay-table-header' style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
paddingBottom: '15px',
borderBottom: '2px solid #e9ecef'
}}
>
<h5 style={{ margin: 0, color: '#495057', fontWeight: '600' }}>Relay Connections</h5>
<span style={{
fontSize: '0.85em',
color: '#6c757d',
backgroundColor: '#e9ecef',
padding: '4px 12px',
borderRadius: '20px'
}}
>
{relaysData.length} relays
</span>
</div>
<div className='relay-list'>
{relaysData.map((relay, index) => (
<div
key={`relay-${index}`} style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '16px',
marginBottom: '12px',
border: '1px solid #e9ecef',
transition: 'all 0.2s ease',
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '12px'
}}
>
{/* Relay Address */}
<div style={{ flex: '1', minWidth: '200px' }}>
<div style={{
fontFamily: 'monospace',
fontSize: '0.9em',
color: '#495057',
fontWeight: '500',
wordBreak: 'break-all'
}}
>
{relay.address}
</div>
</div>
{/* Controls */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
flexWrap: 'wrap'
}}
>
{/* Read/Write Toggles */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
backgroundColor: '#f8f9fa',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #dee2e6'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
fontSize: '0.8em',
color: '#6c757d',
fontWeight: '500',
minWidth: '35px'
}}
>
Read
</span>
<Form.Check
type='switch'
onClick={() => handleReadToggle(relay)}
checked={relay.read}
className='mb-0'
style={{ margin: 0 }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
fontSize: '0.8em',
color: '#6c757d',
fontWeight: '500',
minWidth: '35px'
}}
>
Write
</span>
<Form.Check
type='switch'
onClick={() => handleWriteToggle(relay)}
checked={relay.write}
className='mb-0'
style={{ margin: 0 }}
/>
</div>
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '8px' }}>
<Button
variant='outline-primary'
size='sm'
className='d-flex align-items-center gap-1'
style={{
minWidth: '65px',
fontSize: '0.8em',
padding: '4px 8px',
borderWidth: '1px'
}}
>
<FontAwesomeIcon icon={faShareAlt} size='xs' />
Share
</Button>
<Button
onClick={() => deleteRelay(relay)}
variant='outline-danger'
size='sm'
className='d-flex align-items-center gap-1'
style={{
minWidth: '65px',
fontSize: '0.8em',
padding: '4px 8px',
borderWidth: '1px'
}}
>
<FontAwesomeIcon icon={faTrashAlt} size='xs' />
Delete
</Button>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</Col>
</Row>
<Row className='mt-4'>
<Col>
<div
className='add-relay-container' style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '20px',
border: '1px solid #e9ecef',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
>
<div
className='add-relay-header' style={{
marginBottom: '16px',
paddingBottom: '12px',
borderBottom: '2px solid #e9ecef'
}}
>
<h6 style={{
margin: 0,
color: '#495057',
fontWeight: '600',
fontSize: '1rem'
}}
>
Add New Relay
</h6>
<p style={{
margin: '4px 0 0 0',
fontSize: '0.85em',
color: '#6c757d'
}}
>
Enter the URL of a Nostr relay to add it to your configuration
</p>
</div>
<div
className='add-relay-form' style={{
display: 'flex',
gap: '12px',
alignItems: 'end'
}}
>
<div style={{ flex: '1' }}>
<Form.Label
htmlFor='newRelayAddress'
style={{
fontSize: '0.9em',
fontWeight: '500',
color: '#495057',
marginBottom: '8px'
}}
>
Relay Address
</Form.Label>
<Form.Control
type='text'
id='newRelayAddress'
placeholder='wss://your-relay-address.com'
value={newRelayAddress}
onChange={(e) => setNewRelayAddress(e.target.value)}
style={{
fontFamily: 'monospace',
fontSize: '0.9em',
border: '1px solid #dee2e6',
borderRadius: '6px',
padding: '10px 12px'
}}
/>
</div>
<Button
onClick={() => addRelay(newRelayAddress)}
variant='primary'
style={{
minWidth: '80px',
height: '38px',
borderRadius: '6px',
fontSize: '0.9em',
fontWeight: '500',
padding: '8px 16px'
}}
>
Add
</Button>
</div>
</div>
</Col>
</Row>
</Card.Body>
</Card>
</>
)
}
export default RelaySelectionView
+3 -3
View File
@@ -15,14 +15,14 @@ import GetBalance from './balance'
import Wallet from './bch-wallet' import Wallet from './bch-wallet'
import Placeholder2 from './placeholder2' import Placeholder2 from './placeholder2'
import Placeholder3 from './placeholder3' import Placeholder3 from './placeholder3'
// import ServerSelectView from './servers/select-server-view' import ServerSelectView from './configuration/select-server-view.js'
// import SelectServerButton from './servers/select-server-button' // import SelectServerButton from './servers/select-server-button'
import NftsForSale from './nfts-for-sale' import NftsForSale from './nfts-for-sale'
import BchSend from './bch-send' import BchSend from './bch-send'
import SlpTokens from './slp-tokens' import SlpTokens from './slp-tokens'
import SweepWif from './sweep/index.js' import SweepWif from './sweep/index.js'
import SignMessage from './sign/index.js' import SignMessage from './sign/index.js'
import ServerSelectView from './configuration/select-server-view' import ConfigurationView from './configuration/index.js'
import NostrPost from './nostr/nostr-post/index.js' import NostrPost from './nostr/nostr-post/index.js'
import Profile from './nostr/profile/index.js' import Profile from './nostr/profile/index.js'
import Feeds from './nostr/feeds/index.js' import Feeds from './nostr/feeds/index.js'
@@ -47,7 +47,7 @@ function AppBody (props) {
<Route path='/servers' element={<ServerSelectView appData={appData} />} /> <Route path='/servers' element={<ServerSelectView appData={appData} />} />
<Route path='/sweep' element={<SweepWif appData={appData} />} /> <Route path='/sweep' element={<SweepWif appData={appData} />} />
<Route path='/sign' element={<SignMessage appData={appData} />} /> <Route path='/sign' element={<SignMessage appData={appData} />} />
<Route path='/configuration' element={<ServerSelectView appData={appData} />} /> <Route path='/configuration' element={<ConfigurationView appData={appData} />} />
<Route path='/nostr-post' element={<NostrPost appData={appData} />} /> <Route path='/nostr-post' element={<NostrPost appData={appData} />} />
<Route path='/profile/:npub' element={<Profile appData={appData} />} /> <Route path='/profile/:npub' element={<Profile appData={appData} />} />
<Route path='/feeds' element={<Feeds appData={appData} />} /> <Route path='/feeds' element={<Feeds appData={appData} />} />
@@ -172,7 +172,7 @@ function NftsForSale (props) {
} catch (error) { } catch (error) {
setIconsAreLoaded(true) setIconsAreLoaded(true)
} }
}, [fetchTokenMutableData, appData]) }, [fetchTokenMutableData])
// Check if token data exists in the cache and add it to the tokens object. // Check if token data exists in the cache and add it to the tokens object.
const reviewNftCachedData = useCallback(async (offers) => { const reviewNftCachedData = useCallback(async (offers) => {
@@ -98,17 +98,16 @@ function InfoButton (props) {
</Row> </Row>
{mutableDataCid && ( {mutableDataCid && (
<Row> <Row style={{ paddingTop: '10px' }}>
<Col xs={4}><b>User Data</b>:</Col> <Col xs={4}><b>User Data</b>:</Col>
<Col xs={8}> <Col xs={8}>
<a <Button
href={`/user-data/${props.token.tokenId}`} href={`/user-data/${props.token.tokenId}#single-view`}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='btn btn-link p-0'
> >
View User Data View User Data
</a> </Button>
</Col> </Col>
</Row> </Row>
)} )}
@@ -12,7 +12,7 @@ import InfoButton from './info-button'
import BuyButton from './buy-button' import BuyButton from './buy-button'
function TokenCard (props) { function TokenCard (props) {
const { token, appData, handleRefresh } = props const { token, appData, handleRefresh, hideBuyBtn } = props
const [icon, setIcon] = useState(token.icon) const [icon, setIcon] = useState(token.icon)
const [tokenData, setTokenData] = useState(token.tokenData) const [tokenData, setTokenData] = useState(token.tokenData)
@@ -64,16 +64,16 @@ function TokenCard (props) {
</Row> </Row>
<br /> <br />
<Row> <Row className='text-center'>
<Col> <Col>
<InfoButton token={token} disabled={!token.tokenData} /> <InfoButton token={token} disabled={!token.tokenData} />
</Col> </Col>
<Col /> {!hideBuyBtn && (
<Col>
<Col> <BuyButton token={token} disabled={!token.tokenData} appData={appData} onSuccess={handleRefresh} />
<BuyButton token={token} disabled={!token.tokenData} appData={appData} onSuccess={handleRefresh} /> </Col>
</Col> )}
</Row> </Row>
</Container> </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 export default TokenCard
@@ -3,7 +3,7 @@
*/ */
import React, { useState, useEffect } from 'react' 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 Jdenticon from '@chris.troutner/react-jdenticon'
import CopyOnClick from '../../bch-wallet/copy-on-click.js' import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import FollowBtn from './follow-btn.js' import FollowBtn from './follow-btn.js'
@@ -11,16 +11,21 @@ import FollowBtn from './follow-btn.js'
function ContentCard (props) { function ContentCard (props) {
const { creator, followList, refreshFollowList } = props const { creator, followList, refreshFollowList } = props
const [profile, setProfile] = useState(null) const [profile, setProfile] = useState(null)
const [imageError, setImageError] = useState(false)
useEffect(() => { useEffect(() => {
setProfile(creator?.profile || {}) setProfile(creator?.profile)
}, [creator.profile]) }, [creator.profile])
const goToProfile = () => { const goToProfile = () => {
const profileUrl = `${window.location.origin}/profile/${creator.npub}` const profileUrl = `${window.location.origin}/profile/${creator.npub}#single-view`
window.open(profileUrl, '_blank') window.open(profileUrl, '_blank')
} }
const handleImageError = (type) => {
setImageError(true)
}
return ( return (
<> <>
@@ -34,9 +39,33 @@ function ContentCard (props) {
</div> </div>
<div className='d-none d-md-flex align-items-center gap-4 cursor-pointer'> <div className='d-none d-md-flex align-items-center gap-4 cursor-pointer'>
{/* Profile Picture */} {/* Profile Picture */}
<div className='flex-shrink-0' onClick={goToProfile}> {(profile && profile.picture && !imageError) && (
<Jdenticon size='140' value={creator.npub} /> <div className='flex-shrink-0 max-w-50' onClick={goToProfile}>
</div> <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 */} {/* Creator Info */}
<div className='flex-grow-1'> <div className='flex-grow-1'>
@@ -113,9 +142,33 @@ function ContentCard (props) {
{/* Mobile Layout - Vertical */} {/* Mobile Layout - Vertical */}
<div className='d-flex d-md-none flex-column align-items-center text-center'> <div className='d-flex d-md-none flex-column align-items-center text-center'>
{/* Profile Picture */} {/* Profile Picture */}
<div className='mb-3'> {(profile && profile.picture && !imageError) && (
<Jdenticon size='100' value={creator.npub} onClick={goToProfile} /> <div className='flex-shrink-0 max-w-50 mb-2' onClick={goToProfile}>
</div> <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 */} {/* Creator Info */}
<div className='w-100 mb-3'> <div className='w-100 mb-3'>
@@ -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 React, { useState, useEffect, useCallback } from 'react'
import { Container, Spinner, Dropdown } from 'react-bootstrap' import { Container, Spinner, Dropdown } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faFilter } from '@fortawesome/free-solid-svg-icons' import { faFilter } from '@fortawesome/free-solid-svg-icons'
import axios from 'axios' import axios from 'axios'
// Local libraries // Local libraries
import config from '../../../../config' import config from '../../../../config'
import ContentCard from './content-card' import ContentCard from './content-card'
import { RelayPool } from 'nostr'
// Global variables and constants // Global variables and constants
const SERVER = config.dexServer const SERVER = config.dexServer
@@ -19,67 +30,20 @@ function ContentCreators (props) {
const [followList, setFollowList] = useState([]) 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 getFollowList = useCallback(async () => {
const list = await new Promise((resolve, reject) => { const { nostrKeyPair } = appData.bchWalletState
let list = []
const { nostrKeyPair } = appData.bchWalletState
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf]) const pubKeyHex = nostrKeyPair.pubHex
pool.on('open', relay => { const list = await appData.nostrQueries.getFollowList(pubKeyHex)
relay.subscribe('subid', { limit: 1, kinds: [3], authors: [nostrKeyPair.pubHex] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
/** This ensures to return an empty array if no records are found!
* Applies for new users that don't have a follow list
*/
resolve(list)
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
list = ev.tags
resolve(list)
})
})
console.log('Follow List', list)
setFollowList(list) setFollowList(list)
}, [appData]) }, [appData])
const loadProfile = useCallback(async (pubKey) => {
return new Promise((resolve) => {
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubKey] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
resolve(false)
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
// console.log('profile', profile)
resolve(profile)
} catch (error) {
resolve(false)
}
})
})
}, [])
useEffect(() => { useEffect(() => {
const loadCreators = async () => { const loadCreators = async () => {
try { try {
console.log('loadCreators()')
const creatorsRes = await axios.get(`${SERVER}/sm/list/all/0`) const creatorsRes = await axios.get(`${SERVER}/sm/list/all/0`)
const creators = creatorsRes.data const creators = creatorsRes.data
// console.log('creators', creators) // console.log('creators', creators)
@@ -89,8 +53,9 @@ function ContentCreators (props) {
for (let i = 0; i < creators.length; i++) { for (let i = 0; i < creators.length; i++) {
try { try {
const creator = creators[i] const creator = creators[i]
const profile = await loadProfile(creator.pubkey) const profileRes = await appData.nostrQueries.getProfile(creator.pubkey)
let profile = profileRes
if (!profileRes) profile = {}
// the folowing lines should re-render the ContentCard // the folowing lines should re-render the ContentCard
setCreators(prevCreators => { setCreators(prevCreators => {
const updatedCreators = [...prevCreators] const updatedCreators = [...prevCreators]
@@ -110,7 +75,7 @@ function ContentCreators (props) {
loadCreators() loadCreators()
getFollowList() getFollowList()
} }
}, [loaded, followList, getFollowList, loadProfile]) }, [loaded, getFollowList, appData])
const filteredCreators = useCallback(() => { const filteredCreators = useCallback(() => {
if (!creators || creators.length === 0) { if (!creators || creators.length === 0) {
+175 -16
View File
@@ -1,26 +1,69 @@
/** /**
* Component for displaying nostr posts * Component for displaying nostr posts
*/ */
// Global npm libraries // Global npm libraries
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { Card, Spinner } from 'react-bootstrap' import { Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons' 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 * as nip19 from 'nostr-tools/nip19'
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 CopyOnClick from '../../bch-wallet/copy-on-click.js'
import NostrFormat from '../nostr-format' import NostrFormat from '../nostr-format'
function FeedCard (props) { function FeedCard (props) {
const { post, appData, profiles } = props const { post, appData, profiles } = props
const { nostrKeyPair, writeRelays } = appData.bchWalletState
const [profile, setProfile] = useState(profiles[post.pubkey]) const [profile, setProfile] = useState(profiles[post.pubkey])
const [npub, setNpub] = useState('') 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 {
// Get all post likes events
const likesArr = await appData.nostrQueries.getPostLikes(post.id)
const likesCount = likesArr.length
// 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 // Get npub from pubkey
useEffect(() => { useEffect(() => {
const npub = nip19.npubEncode(post.pubkey) const npub = nip19.npubEncode(post.pubkey)
setNpub(npub) setNpub(npub)
}, [post])
// Handle post likes
if (!likesFetched && nostrKeyPair.pubHex) {
handleLikes(post, nostrKeyPair.pubHex)
}
}, [post, nostrKeyPair, handleLikes, likesFetched])
// Verify if pubkey profile exists into the profiles state // Verify if pubkey profile exists into the profiles state
useEffect(() => { useEffect(() => {
@@ -29,18 +72,70 @@ function FeedCard (props) {
} }
}, [profiles, post]) }, [profiles, post])
// copy to clipboard
const copyToClipboard = useCallback((value) => {
setIsClicked(true)
appData.appUtil.copyToClipboard(value)
setTimeout(() => setIsClicked(false), 200)
}, [appData])
// get short npub // get short npub
const getShortNpub = useCallback((npub) => { const getShortNpub = useCallback((npub) => {
return npub.slice(0, 8) + '...' + npub.slice(-5) 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)
// 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],
['p', nostrKeyPair.pubHex]
],
content
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
writeRelays.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}`)
// 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}`)
}
})
await handleLikes(post, nostrKeyPair.pubHex)
setLikesFetched(true)
} catch (error) {
console.warn(error)
setLikesFetched(true)
}
}
const goToProfile = () => {
const profileUrl = `${window.location.origin}/profile/${npub}#single-view`
window.open(profileUrl, '_blank')
}
return ( return (
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'> <Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'> <Card.Body className='p-3'>
@@ -53,13 +148,27 @@ function FeedCard (props) {
background: 'linear-gradient(45deg, #6c757d, #495057)' 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>
<div className='flex-grow-1'> <div className='flex-grow-1'>
<div className='fw-bold mb-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 && ( {!profile?.name && (
<span> <span style={{ cursor: 'pointer' }} onClick={goToProfile}>
{post.pubkey.slice(0, 8) + '...'} {post.pubkey.slice(0, 8) + '...'}
{!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />} {!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />}
</span> </span>
@@ -71,14 +180,20 @@ function FeedCard (props) {
title='Copy to clipboard' title='Copy to clipboard'
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
transform: isClicked ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease', transition: 'transform 0.1s ease',
display: 'inline-block' display: 'inline-block',
marginRight: '5px'
}} }}
onClick={() => copyToClipboard(npub)} onClick={goToProfile}
> >
{getShortNpub(npub)} {getShortNpub(npub)}
</span> </span>
<CopyOnClick
walletProp='npub'
appData={props.appData}
value={npub}
/>
</small> </small>
)} )}
</div> </div>
@@ -91,6 +206,50 @@ function FeedCard (props) {
<NostrFormat content={post.content} /> <NostrFormat content={post.content} />
</div> </div>
</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.Body>
</Card> </Card>
@@ -5,40 +5,20 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Container, Spinner } from 'react-bootstrap' import { Container, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr' // Local libraries
import FeedCard from './feed-card' import FeedCard from './feed-card'
function Following (props) { function Following (props) {
const { appData, posts, profiles } = props const { appData, posts, profiles } = props
const { nostrQueries } = appData
const [followingPosts, setFollowingPosts] = useState([]) const [followingPosts, setFollowingPosts] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
useEffect(() => { useEffect(() => {
const getFollowingPosts = async () => { const getFollowingPosts = async () => {
const followingList = await new Promise((resolve, reject) => { console.log('getFollowingPosts')
let list = [] const { nostrKeyPair } = appData.bchWalletState
const { nostrKeyPair } = appData.bchWalletState const followingList = await nostrQueries.getFollowList(nostrKeyPair.pubHex)
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [3], authors: [nostrKeyPair.pubHex] })
})
pool.on('eose', relay => {
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 // Filter posts by following list
let filteredPosts = [] let filteredPosts = []
@@ -55,7 +35,7 @@ function Following (props) {
setLoaded(true) setLoaded(true)
} }
getFollowingPosts() getFollowingPosts()
}, [appData, posts]) }, [appData, posts, nostrQueries])
return ( return (
<Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}> <Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}>
+24 -68
View File
@@ -1,98 +1,54 @@
/* /*
Component for reading the nostr feeds. Component for reading the nostr feeds.
*/ */
// Global npm libraries // Global npm libraries
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect } from 'react'
import { Container, Nav, Tab, Spinner } from 'react-bootstrap' import { Container, Nav, Tab, Spinner } from 'react-bootstrap'
// Local libraries
import Feed from './feed' import Feed from './feed'
import Following from './following' import Following from './following'
import { RelayPool } from 'nostr'
function Feeds (props) { function Feeds (props) {
const { appData } = props const { appData } = props
const [activeTab, setActiveTab] = useState(appData.lastFeedTab) const [activeTab, setActiveTab] = useState(appData.lastFeedTab)
const { bchWalletState } = appData
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [profiles, setProfiles] = useState({}) const [profiles, setProfiles] = useState({})
// function to fetch profile and set it to profiles state // Load data on component mount.
const fetchProfile = useCallback((pubkey) => { useEffect(() => {
// no fetch profile again if it exist const loadData = async () => {
setProfiles(currentProfiles => { // Get feeds
if (currentProfiles[pubkey]) { const feeds = await appData.nostrQueries.getGlobalFeeds()
return currentProfiles setPosts(feeds)
} setLoaded(true)
return currentProfiles
})
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]) const exist = loadedProfiles.find((val) => { return val === pubKey })
pool.on('open', relay => { if (exist) { continue }
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubkey] }) // Fech profile.
}) const profile = await appData.nostrQueries.getProfile(pubKey)
loadedProfiles.push(pubKey) // mark as loaded
pool.on('eose', relay => { // Update profile state
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)
setProfiles(currentProfiles => { setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles } const newProfiles = { ...currentProfiles }
newProfiles[pubkey] = profile newProfiles[pubKey] = profile
return newProfiles 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) { if (!loaded) {
start() loadData()
} }
}, [bchWalletState, loaded, fetchProfile]) }, [loaded, appData])
const onChangeTab = (tab) => { const onChangeTab = (tab) => {
setActiveTab(tab) setActiveTab(tab)
@@ -26,7 +26,7 @@ const NostrFormat = ({ content }) => {
} }
return ( 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 // Global npm libraries
import React from 'react' import React from 'react'
import { Container } from 'react-bootstrap' import { Container } from 'react-bootstrap'
// Local libraries
import ProfilePost from './profile-post' import ProfilePost from './profile-post'
import PublicPost from './public-post' import PublicPost from './public-post'
@@ -9,10 +9,12 @@ import Accordion from 'react-bootstrap/Accordion'
import { finalizeEvent } from 'nostr-tools/pure' import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay' import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import { RelayPool } from 'nostr'
// Local libraries
function ProfilePost (props) { function ProfilePost (props) {
const { bchWalletState } = props.appData const { appData } = props
const { bchWalletState, writeRelays } = appData
const [accordionKey, setAccordionKey] = useState(null) const [accordionKey, setAccordionKey] = useState(null)
const [onFetch, setOnFetch] = useState(false) const [onFetch, setOnFetch] = useState(false)
const [formLoaded, setFormLoaded] = useState(false) const [formLoaded, setFormLoaded] = useState(false)
@@ -31,40 +33,19 @@ function ProfilePost (props) {
useEffect(() => { useEffect(() => {
// Get Last post from // Get Last post from
const getLastPost = () => { const getLastProfilePost = async () => {
const { nostrKeyPair } = bchWalletState const { nostrKeyPair } = appData.bchWalletState
const lastProfile = await appData.nostrQueries.getProfile(nostrKeyPair.pubHex)
const psf = 'wss://nostr-relay.psfoundation.info' if (lastProfile) {
setFormData(lastProfile)
const pool = RelayPool([psf]) }
pool.on('open', relay => { setFormLoaded(true)
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) { if (!formLoaded) {
getLastPost() getLastProfilePost()
} }
}, [bchWalletState, formLoaded]) }, [appData, formLoaded])
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value } = e.target const { name, value } = e.target
@@ -90,7 +71,7 @@ function ProfilePost (props) {
const privateKeyBin = hexToBytes(nostrKeyPair.privHex) const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list // Relay list
const psf = 'wss://nostr-relay.psfoundation.info' // const psf = 'wss://nostr-relay.psfoundation.info'
const formDataString = JSON.stringify(formData) const formDataString = JSON.stringify(formData)
@@ -107,16 +88,24 @@ function ProfilePost (props) {
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin) const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent) console.log('signedEvent: ', signedEvent)
// Connect to a relay. // Publish the post to each relay.
const relay = await Relay.connect(psf) writeRelays.map(async (relayUrl) => {
console.log(`connected to ${relay.url}`) try {
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay. // Publish the message to the relay.
const result = await relay.publish(signedEvent) const result = await relay.publish(signedEvent)
console.log('result: ', result) 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!') setSuccessMsg('Post successfully published!')
setOnFetch(false) setOnFetch(false)
} catch (error) { } 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 // Global npm libraries
@@ -10,10 +10,12 @@ import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay' import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
// Local libraries
function PublicPost (props) { function PublicPost (props) {
const [accordionKey, setAccordionKey] = useState(null) const [accordionKey, setAccordionKey] = useState('0')
const [onFetch, setOnFetch] = useState(false) const [onFetch, setOnFetch] = useState(false)
const { bchWalletState } = props.appData const { bchWalletState, writeRelays } = props.appData
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
content: '' content: ''
}) })
@@ -44,9 +46,6 @@ function PublicPost (props) {
// Convert private key to binary // Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex) const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
const psf = 'wss://nostr-relay.psfoundation.info'
// BCH address of the user. // BCH address of the user.
const bchAddr = bchWalletState.address const bchAddr = bchWalletState.address
@@ -63,16 +62,24 @@ function PublicPost (props) {
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin) const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent) console.log('signedEvent: ', signedEvent)
// Connect to a relay. // Publish the post to each relay.
const relay = await Relay.connect(psf) writeRelays.map(async (relayUrl) => {
console.log(`connected to ${relay.url}`) try {
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay. // Publish the message to the relay.
const result = await relay.publish(signedEvent) const result = await relay.publish(signedEvent)
console.log('result: ', result) 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() resetForm()
setSuccessMsg('Post successfully published!') setSuccessMsg('Post successfully published!')
setOnFetch(false) setOnFetch(false)
@@ -9,7 +9,7 @@ import ProfileRead from './profile-read.js'
import PublicRead from './public-read.js' import PublicRead from './public-read.js'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import SlpTokensDisplay from './slp-tokens-display' import SlpTokensDisplay from './slp-tokens-display'
import NFTForSale from './nft-for-sale.js'
function Profile (props) { function Profile (props) {
const [profile, setProfile] = useState(false) const [profile, setProfile] = useState(false)
const { npub } = useParams() const { npub } = useParams()
@@ -17,9 +17,10 @@ function Profile (props) {
return ( return (
<> <>
<Container> <Container>
<ProfileRead {...props} setProfile={setProfile} npub={npub} /> <ProfileRead {...props} onProfileRead={setProfile} npub={npub} />
<PublicRead {...props} profile={profile} npub={npub} /> <PublicRead {...props} profile={profile} npub={npub} />
<SlpTokensDisplay {...props} npub={npub} /> <SlpTokensDisplay {...props} npub={npub} />
<NFTForSale {...props} npub={npub} />
</Container> </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,54 +1,36 @@
/** /**
* Component for read nostr information kind 0 * Component to read nostr information kind 0 (normal posts)
*
*/ */
// Global npm libraries // Global npm libraries
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Container, Button } from 'react-bootstrap' import { Container, Button } from 'react-bootstrap'
import CopyOnClick from '../../bch-wallet/copy-on-click.js' import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser, faGlobe } from '@fortawesome/free-solid-svg-icons' 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' import * as nip19 from 'nostr-tools/nip19'
// Local libraries
import NostrFormat from '../nostr-format'
function ProfileRead (props) { function ProfileRead (props) {
const { npub } = props const { npub, appData } = props
const { bchWalletState } = props.appData const { onProfileRead } = props
console.log('npub prop', npub) const [profile, setProfile] = useState({})
const { setProfile } = props
const [post, setPost] = useState({})
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [imageError, setImageError] = useState({ picture: false, banner: false }) const [imageError, setImageError] = useState({ picture: false, banner: false })
useEffect(() => { useEffect(() => {
const start = () => { const start = async () => {
const pubHexData = nip19.decode(npub) const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data const pubHex = pubHexData.data
console.log('pubhex', pubHex)
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf]) const profile = await appData.nostrQueries.getProfile(pubHex)
pool.on('open', relay => { if (profile) {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubHex] }) onProfileRead(profile)
setLoaded(true) setProfile(profile)
}) }
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)
}
})
setLoaded(true) setLoaded(true)
} }
@@ -56,7 +38,7 @@ function ProfileRead (props) {
if (!loaded && npub) { if (!loaded && npub) {
start() start()
} }
}, [bchWalletState, loaded, setProfile, npub]) }, [loaded, onProfileRead, npub, appData])
const handleImageError = (type) => { const handleImageError = (type) => {
setImageError(prev => ({ ...prev, [type]: true })) setImageError(prev => ({ ...prev, [type]: true }))
@@ -69,10 +51,10 @@ function ProfileRead (props) {
return ( return (
<Container> <Container>
{/* Banner Section */} {/* Banner Section */}
{post.banner && !imageError.banner && ( {profile.banner && !imageError.banner && (
<div className='position-relative'> <div className='position-relative'>
<img <img
src={post.banner} src={profile.banner}
alt='Profile banner' alt='Profile banner'
className='w-100 rounded-4 shadow-sm' className='w-100 rounded-4 shadow-sm'
style={{ height: '200px', objectFit: 'cover' }} style={{ height: '200px', objectFit: 'cover' }}
@@ -85,11 +67,11 @@ 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'> <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 */} {/* Profile Picture */}
<div style={{ width: '120px', height: '120px' }} className='mb-3 mb-md-0'> <div style={{ width: '120px', height: '120px' }} className='mb-3 mb-md-0'>
{post.picture && !imageError.picture {profile.picture && !imageError.picture
? ( ? (
<img <img
src={post.picture} src={profile.picture}
alt='Profile picture' alt='Profile'
className='rounded-circle shadow w-100 h-100' className='rounded-circle shadow w-100 h-100'
style={{ objectFit: 'cover' }} style={{ objectFit: 'cover' }}
onError={() => handleImageError('picture')} onError={() => handleImageError('picture')}
@@ -112,39 +94,39 @@ function ProfileRead (props) {
{/* Profile Information */} {/* 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'> <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'> <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='text-truncate me-2 mb-2 mb-md-0'>
<span className='d-md-none'> <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>
<span className='d-none d-md-inline'> <span className='d-none d-md-inline'>
{bchWalletState.nostrKeyPair.npub} {npub}
</span> </span>
</span> </span>
<CopyOnClick walletProp='npub' appData={props.appData} value={bchWalletState.nostrKeyPair.npub} /> <CopyOnClick walletProp='npub' appData={props.appData} value={npub} />
</div> </div>
{/* About Section */} {/* About Section */}
{post.about && ( {profile.about && (
<div className='fs-6 text-secondary'> <div className='fs-6 text-secondary'>
<NostrFormat content={post.about} /> <NostrFormat content={profile.about} />
</div> </div>
)} )}
{/* Website Link */} {/* Website Link */}
{post.website && ( {profile.website && (
<div className='mb-3 d-flex align-items-center gap-2'> <div className='mb-3 d-flex align-items-center gap-2'>
<FontAwesomeIcon icon={faGlobe} className='text-muted' size='sm' /> <FontAwesomeIcon icon={faGlobe} className='text-muted' size='sm' />
<a <a
href={post.website.startsWith('http') ? post.website : `https://${post.website}`} href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-decoration-none text-muted small' className='text-decoration-none text-muted small'
style={{ wordBreak: 'break-all' }} style={{ wordBreak: 'break-all' }}
> >
{post.website} {profile.website}
</a> </a>
</div> </div>
)} )}
@@ -6,46 +6,38 @@ import React, { useEffect, useState } from 'react'
import { Container, Card, Spinner } from 'react-bootstrap' import { Container, Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons' import { faUser } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import NostrFormat from '../nostr-format' import NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
function PublicRead (props) { function PublicRead (props) {
const { npub } = props const { npub, appData } = props
const { bchWalletState } = props.appData const { bchWalletState } = appData
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [profilePictureError, setProfilePictureError] = useState(false)
useEffect(() => { useEffect(() => {
// Get Last post from a author // Get Last post from a author
const start = () => { const start = async () => {
const pubHexData = nip19.decode(npub) const pubKeyHex = appData.nostrQueries.npubToHex(npub)
const pubHex = pubHexData.data const feeds = await appData.nostrQueries.getUserFeeds(pubKeyHex)
console.log('pubhex', pubHex) setPosts(feeds)
const psf = 'wss://nostr-relay.psfoundation.info' setLoaded(true)
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])
})
} }
if (!loaded && npub) { if (!loaded && npub) {
start() start()
} }
}, [bchWalletState, loaded, npub]) }, [bchWalletState, loaded, npub, appData])
const handleProfilePictureError = () => {
setProfilePictureError(true)
}
const handleProfilePictureLoad = () => {
setProfilePictureError(false)
}
return ( return (
<Container className='mt-4 mb-5'> <Container className='mt-4 mb-5'>
@@ -74,12 +66,25 @@ function PublicRead (props) {
background: 'linear-gradient(45deg, #6c757d, #495057)' 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>
<div className='flex-grow-1'> <div className='flex-grow-1'>
<div className='fw-bold mb-1'>{props.profile?.name}</div> <div className='fw-bold mb-1'>{props.profile?.name}</div>
<small className='text-muted'> <small className='text-muted'>
{`${bchWalletState.nostrKeyPair.npub.slice(0, 8)}...${bchWalletState.nostrKeyPair.npub.slice(-5)}`} {`${npub.slice(0, 8)}...${npub.slice(-5)}`}
</small> </small>
</div> </div>
<small className='text-muted'> <small className='text-muted'>
@@ -91,7 +91,7 @@ function SlpTokensDisplay (props) {
const getTokens = async () => { const getTokens = async () => {
try { try {
const { nostrKeyPair } = bchWalletState const { nostrKeyPair } = bchWalletState
if (nostrKeyPair.npub === npub) { if (nostrKeyPair?.npub === npub) {
setTokens(bchWalletState.slpTokens) setTokens(bchWalletState.slpTokens)
setLoaded(true) setLoaded(true)
return return
@@ -13,11 +13,11 @@ import { Button, Modal, Container, Row, Col } from 'react-bootstrap'
// returned with a link. Otherwise the original string is returned. // returned with a link. Otherwise the original string is returned.
function linkIfUrl (url) { function linkIfUrl (url) {
// Convert the URL into a link if it contains 'http' // 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>) 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. // Convert to a Filecoin link if its an IPFS reference.
const cid = url.substring(7) const cid = url.substring(7)
+107 -103
View File
@@ -15,8 +15,7 @@ import Logo from './psf-logo.png'
function NavMenu (props) { function NavMenu (props) {
// Get the current path // Get the current path
const { currentPath, bchWalletState } = props.appData const { currentPath, bchWalletState, isSingleView } = props.appData
// Navbar state // Navbar state
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
@@ -25,125 +24,130 @@ function NavMenu (props) {
// Collapse the navbar // Collapse the navbar
setExpanded(false) setExpanded(false)
} }
const goToHome = () => {
if (isSingleView) {
window.location.href = '/'
}
}
return ( return (
<> <>
<Navbar expanded={expanded} onToggle={setExpanded} expand='xxxl' bg='dark' variant='dark' style={{ paddingRight: '20px' }}> <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' />{' '} <Image src={Logo} thumbnail width='50' />{' '}
DEX DEX
</Navbar.Brand> </Navbar.Brand>
<Navbar.Toggle aria-controls='responsive-navbar-nav' /> {!isSingleView && <Navbar.Toggle aria-controls='responsive-navbar-nav' />}
<Navbar.Collapse id='responsive-navbar-nav'> {!isSingleView && (
<Nav className='mr-auto'> <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 <NavLink
className={(currentPath === '/nfts-for-sale' || currentPath === '/') ? 'nav-link-active' : 'nav-link-inactive'} className={currentPath === '/offers' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nfts-for-sale' to='/offers'
onClick={handleClickEvent} onClick={handleClickEvent}
> >
NFTs Fungible Tokens
</NavLink> </NavLink>
<NavLink <hr />
className={currentPath === '/offers' ? 'nav-link-active' : 'nav-link-inactive'}
to='/offers'
onClick={handleClickEvent}
>
Fungible Tokens
</NavLink>
<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 <hr />
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>
<hr /> <NavLink
className={(currentPath === '/bch') ? 'nav-link-active' : 'nav-link-inactive'}
to='/bch'
onClick={handleClickEvent}
>
BCH
</NavLink>
<NavLink <NavLink
className={(currentPath === '/bch') ? 'nav-link-active' : 'nav-link-inactive'} className={currentPath === '/slp-tokens' ? 'nav-link-active' : 'nav-link-inactive'}
to='/bch' to='/slp-tokens'
onClick={handleClickEvent} onClick={handleClickEvent}
> >
BCH Tokens
</NavLink> </NavLink>
<NavLink <NavLink
className={currentPath === '/slp-tokens' ? 'nav-link-active' : 'nav-link-inactive'} className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
to='/slp-tokens' to='/wallet'
onClick={handleClickEvent} onClick={handleClickEvent}
> >
Tokens Wallet
</NavLink> </NavLink>
<NavLink <NavLink
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'} className={(currentPath === '/balance') ? 'nav-link-active' : 'nav-link-inactive'}
to='/wallet' to='/balance'
onClick={handleClickEvent} onClick={handleClickEvent}
>
Check Balance
</NavLink>
> <NavLink
Wallet className={(currentPath === '/sweep') ? 'nav-link-active' : 'nav-link-inactive'}
</NavLink> 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 </Nav>
className={(currentPath === '/balance') ? 'nav-link-active' : 'nav-link-inactive'} </Navbar.Collapse>
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>
</Navbar> </Navbar>
</> </>
) )
+6 -1
View File
@@ -18,7 +18,12 @@ const config = {
// dexServer: 'http://localhost:5700', // dexServer: 'http://localhost:5700',
nostrTopic: 'bch-dex-test-topic-02', nostrTopic: 'bch-dex-test-topic-02',
nostrRelay: 'wss://nostr-relay.psfoundation.info' nostrRelay: 'wss://nostr-relay.psfoundation.info',
nostrRelays: [
'wss://nostr-relay.psfoundation.info',
'wss://nos.lol',
'wss://relay.damus.io'
]
} }
+64 -11
View File
@@ -1,25 +1,31 @@
import { useState } from 'react' import { useRef, useState } from 'react'
// import { useQueryParam, StringParam } from 'use-query-params' // import { useQueryParam, StringParam } from 'use-query-params'
import useLocalStorageState from 'use-local-storage-state' import useLocalStorageState from 'use-local-storage-state'
import AppUtil from '../util' import AppUtil from '../util'
import NostrQueries from '../services/nostr-queries'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
function useAppState () { function useAppState () {
const location = useLocation() const location = useLocation()
// Default local storage object
const localStorageDefault = {
serverUrl: 'https://free-bch.fullstack.cash', // Default server
relays: [
{ address: 'wss://nostr-relay.psfoundation.info', read: true, write: true },
{ address: 'wss://relay.damus.io', read: true, write: true }
],
nftData: {},
lastFeedTab: 'feed'
}
// Load Local storage Data // Load Local storage Data
const [lsState, setLSState, { removeItem }] = useLocalStorageState('bchWalletState-template', { const [lsState, setLSState, { removeItem }] = useLocalStorageState('bchWalletState-template', {
ssr: true, ssr: true,
defaultValue: { defaultValue: localStorageDefault
serverUrl: 'https://free-bch.fullstack.cash' // Default server
},
nftData: {},
lastFeedTab: 'feed'
}) })
console.log('lsState: ', lsState) console.log('lsState: ', lsState)
// Initialize data states // Initialize data states
const [serverUrl, setServerUrl] = useState(lsState.serverUrl) // Default server url const [serverUrl, setServerUrl] = useState(lsState.serverUrl) // Default server url
const [menuState, setMenuState] = useState(0) const [menuState, setMenuState] = useState(0)
@@ -27,7 +33,7 @@ function useAppState () {
const [servers, setServers] = useState([]) const [servers, setServers] = useState([])
const [dexLib, setDexLib] = useState(false) const [dexLib, setDexLib] = useState(false)
const [nostr, setNostr] = useState(false) const [nostr, setNostr] = useState(false)
const [lastFeedTab, setLastFeedTab] = useState(lsState.lastFeedTab || 'feed') const [lastFeedTab, setLastFeedTab] = useState(lsState.lastFeedTab || localStorageDefault.lastFeedTab)
// Startup state management // Startup state management
const [asyncInitStarted, setAsyncInitStarted] = useState(false) const [asyncInitStarted, setAsyncInitStarted] = useState(false)
@@ -39,10 +45,19 @@ function useAppState () {
const [modalBody, setModalBody] = useState([]) const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false) const [hideSpinner, setHideSpinner] = useState(false)
const [denyClose, setDenyClose] = useState(false) const [denyClose, setDenyClose] = useState(false)
const [isSingleView, setIsSingleView] = useState(false)
// NFTs for sale stored data to improve performance // NFTs for sale stored data to improve performance
const [nftForSaleCacheData, setNftForSaleCacheData] = useState(lsState.nftData || {}) const [nftForSaleCacheData, setNftForSaleCacheData] = useState(lsState.nftData || {})
// Relays
const [relaysData, setRelaysData] = useState(lsState.relays || localStorageDefault.relays) // All relays data
const [readRelays, setReadRelays] = useState(relaysData.filter(relay => relay.read).map(relay => relay.address)) // Read relays
const [writeRelays, setWriteRelays] = useState(relaysData.filter(relay => relay.write).map(relay => relay.address)) // Write relays
// Nostr queries service
const nostrQueriesRef = useRef(new NostrQueries({ relays: readRelays }))
// The wallet state makes this a true progressive web app (PWA). As // The wallet state makes this a true progressive web app (PWA). As
// balances, UTXOs, and tokens are retrieved, this state is updated. // balances, UTXOs, and tokens are retrieved, this state is updated.
// properties are enumerated here for the purpose of documentation. // properties are enumerated here for the purpose of documentation.
@@ -95,7 +110,7 @@ function useAppState () {
return newBchWalletState return newBchWalletState
}) })
// console.log(`New wallet state: ${JSON.stringify(bchWalletState, null, 2)}`) // console.log(`New wallet state: ${JSON.stringify(bchWalletState, null, 2)}`)
} catch (err) { } catch (err) {
console.error('Error in App.js updateBchWalletState()') console.error('Error in App.js updateBchWalletState()')
throw err throw err
@@ -112,6 +127,35 @@ function useAppState () {
setNftForSaleCacheData(allCacheData) // Update the state setNftForSaleCacheData(allCacheData) // Update the state
updateLocalStorage({ nftData: allCacheData }) // Update the local storage updateLocalStorage({ nftData: allCacheData }) // Update the local storage
} }
// Update relays data
function updateRelaysData (relaysData) {
setRelaysData(relaysData)
updateLocalStorage({ relays: relaysData }) // Update the local storage
// get the relay addresses with read set to true
const readRelays = relaysData.filter(relay => relay.read).map(relay => relay.address)
setReadRelays(readRelays)
console.log('readRelays: ', readRelays)
// get the relay addresses with write set to true
const writeRelays = relaysData.filter(relay => relay.write).map(relay => relay.address)
setWriteRelays(writeRelays)
console.log('writeRelays: ', writeRelays)
nostrQueriesRef.current = new NostrQueries({ relays: readRelays })
}
// Restore relays data
function restoreRelaysData () {
const relaysData = [...localStorageDefault.relays] // Create a new array in order to detect changes
setRelaysData(relaysData)
updateLocalStorage({ relays: relaysData })
// get the relay addresses with read set to true
const readRelays = relaysData.filter(relay => relay.read).map(relay => relay.address)
setReadRelays(readRelays)
console.log('readRelays: ', readRelays)
// get the relay addresses with write set to true
const writeRelays = relaysData.filter(relay => relay.write).map(relay => relay.address)
setWriteRelays(writeRelays)
console.log('writeRelays: ', writeRelays)
nostrQueriesRef.current = new NostrQueries({ relays: readRelays })
}
return { return {
serverUrl, serverUrl,
@@ -153,7 +197,16 @@ function useAppState () {
setNftForSaleCacheData, setNftForSaleCacheData,
updateNFTCachedData, updateNFTCachedData,
lastFeedTab, lastFeedTab,
setLastFeedTab setLastFeedTab,
isSingleView,
setIsSingleView,
nostrQueries: nostrQueriesRef.current,
relaysData,
updateRelaysData,
restoreRelaysData,
readRelays,
writeRelays
} }
} }
+41
View File
@@ -283,6 +283,47 @@ class AsyncLoad {
npub 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) { function sleep (ms) {
+305
View File
@@ -0,0 +1,305 @@
/**
* Nostr class for query into relay pools
*
*/
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
export default class NostrQueries {
constructor ({ relays }) {
this.relays = relays || []
}
setRelays (relays) {
this.relays = relays
}
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 {
if (this.relays.length === 0) {
return false
}
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()
})
pool.on('error', (relay) => {
relay.close()
resolve(false)
})
})
// Stop looking for profile if found
if (profile) {
return profile
}
}
} catch (error) {
console.warn(error)
}
}
// Get Feeds by user pubkey
async getUserFeeds (pubHex) {
try {
if (this.relays.length === 0) {
return []
}
let feeds = await new Promise((resolve) => {
let list = []
const closedRelays = []
const pool = RelayPool(this.relays)
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] })
})
pool.on('eose', relay => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
list = [...list, ev]
})
pool.on('error', (relay) => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
} // Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(list)
}
})
})
// 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 {
if (this.relays.length === 0) {
return []
}
let feeds = await new Promise((resolve, reject) => {
let list = []
const closedRelays = []
const pool = RelayPool(this.relays)
// 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()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
console.log('post retrieved from ', relay.url, ev.sig)
list = [...list, ev]
})
pool.on('error', (relay) => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(list)
}
})
})
// 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) {
if (this.relays.length === 0) {
return []
}
return new Promise((resolve, reject) => {
let list = []
const closedRelays = []
const pool = RelayPool(this.relays)
// const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [3], authors: [pubHex] })
})
pool.on('eose', relay => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === 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]
})
pool.on('error', (relay) => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(list)
}
})
})
}
// Get event likes
async getPostLikes (postId) {
try {
if (this.relays.length === 0) {
return []
}
let likesRes = await new Promise((resolve) => {
const likes = []
const closedRelays = []
const pool = RelayPool(this.relays)
pool.on('open', relay => {
relay.subscribe('subid', { kinds: [7], '#e': [postId] })
})
pool.on('eose', relay => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === 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
}
})
pool.on('error', (relay) => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(likes)
}
})
})
// 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)
}
}
}