Compare commits

...
9 changed files with 6051 additions and 2103 deletions
+5626 -2035
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -22,6 +22,7 @@
"query-string": "7.1.1",
"react": "19.0.0",
"react-bootstrap": "2.10.7",
"react-bs-datatable": "3.15.0",
"react-dom": "19.0.0",
"react-markdown": "10.1.0",
"react-router-dom": "7.1.3",
+3
View File
@@ -41,6 +41,9 @@
width: auto!important;
}
.cursor-pointer {
cursor: pointer;
}
/* Remove input number arrows Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
+2
View File
@@ -27,6 +27,7 @@ import NostrPost from './nostr/nostr-post/index.js'
import NostrRead from './nostr/nostr-read/index.js'
import GlobalFeed from './nostr/global-feed/index.js'
import UserDataReview from './user-data-review'
import Offers from './offers'
function AppBody (props) {
// Dependency injection through props
const appData = props.appData
@@ -50,6 +51,7 @@ function AppBody (props) {
<Route path='/nostr-read' element={<NostrRead appData={appData} />} />
<Route path='/global-feed' element={<GlobalFeed appData={appData} />} />
<Route path='/user-data/:cid' element={<UserDataReview appData={appData} />} />
<Route path='/offers' element={<Offers appData={appData} />} />
</Routes>
{/** Show in all paths except the servers view */}
{/* {appData.currentPath !== '/servers' && <SelectServerButton linkTo='/servers' appData={appData} />} */}
@@ -3,11 +3,11 @@
workflow:
1. Load offers
2. display token cards with current offers data
2. Display token cards with current Offer data
3. Load tokens data
4. add data to offers and update card with new data
4. Add data to offers and update card with new data
5. Load tokens icons
6. add icons to offers and update card with new icons
6. Add icons to offers and update card with new icons
*/
// Global npm libraries
@@ -20,6 +20,7 @@ import { faRedo } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import config from '../../../config'
import TokenCard from './token-card'
// Global variables and constants
const SERVER = config.dexServer
@@ -173,7 +174,7 @@ function NftsForSale (props) {
}
}, [fetchTokenMutableData, appData])
// Check if exiist token data 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 cacheData = appData.nftForSaleCacheData
console.log(`Token data from cache: ${JSON.stringify(cacheData, null, 2)}`)
@@ -189,7 +190,7 @@ function NftsForSale (props) {
}
}, [appData])
// Check if exiist token data 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 updateNFTCachedData = useCallback(async (offers) => {
// Map all offers
for (let i = 0; i < offers.length; i++) {
@@ -216,7 +217,7 @@ function NftsForSale (props) {
await reviewNftCachedData(offers)
// set state to start displaying tokens cards.
setOffers(offers)
// Load tokens data for any updates to the token data
// Load tokens data for any updates to the token data
await lazyLoadTokenData(offers)
// Load tokens icons from mutable data
await lazyLoadMutableData(offers)
@@ -0,0 +1,99 @@
/**
* Component for reading global nostr posts
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import * as nip19 from 'nostr-tools/nip19'
function FeedCard (props) {
const { post, appData, profiles } = props
const [profile, setProfile] = useState(profiles[post.pubkey])
const [npub, setNpub] = useState('')
const [isClicked, setIsClicked] = useState(false)
// Get npub from pubkey
useEffect(() => {
const npub = nip19.npubEncode(post.pubkey)
setNpub(npub)
}, [post])
// Verify if pubkey profile exists into the profiles state
useEffect(() => {
if (profiles[post.pubkey]) {
setProfile(profiles[post.pubkey])
}
}, [profiles, post])
// copy to clipboard
const copyToClipboard = useCallback((value) => {
setIsClicked(true)
appData.appUtil.copyToClipboard(value)
setTimeout(() => setIsClicked(false), 200)
}, [appData])
// get short npub
const getShortNpub = useCallback((npub) => {
return npub.slice(0, 8) + '...' + npub.slice(-5)
}, [])
return (
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'>
<div className='d-flex align-items-center gap-3 mb-3'>
<div
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center'
style={{
width: '48px',
height: '48px',
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' />
</div>
<div className='flex-grow-1'>
<div className='fw-bold mb-1'>
{profile && profile.name && <span>{profile.name}</span>}
{!profile?.name && (
<span>
{post.pubkey.slice(0, 8) + '...'}
<Spinner animation='border' size='sm' className='ms-2' />
</span>
)}
</div>
{npub && (
<small className='text-muted'>
<span
title='Copy to clipboard'
style={{
cursor: 'pointer',
transform: isClicked ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease',
display: 'inline-block'
}}
onClick={() => copyToClipboard(npub)}
>
{getShortNpub(npub)}
</span>
</small>
)}
</div>
<small className='text-muted'>
{new Date(post.created_at * 1000).toLocaleString()}
</small>
</div>
<div className='mb-4'>
<div className='fs-5 ms-5 text-dark' style={{ lineHeight: '1.3' }}>
{post.content}
</div>
</div>
</Card.Body>
</Card>
)
}
export default FeedCard
@@ -2,18 +2,53 @@
* Component for reading global nostr posts
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import React, { useEffect, useState, useCallback } from 'react'
import { Container, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr'
import FeedCard from './feed-card'
function Feed (props) {
const { bchWalletState } = props.appData
const { appData } = props
const { bchWalletState } = appData
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
const [profiles, setProfiles] = useState({})
// function to fetch profile and set it to profiles state
const fetchProfile = useCallback((pubkey) => {
// no fetch profile again if it exist
if (profiles[pubkey]) {
console.log('profile exists', profiles[pubkey])
return profiles[pubkey]
}
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubkey] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
const newProfiles = { ...profiles }
newProfiles[pubkey] = profile
setProfiles(newProfiles)
} catch (error) {
console.log('Error parsing profile', error)
}
})
}, [profiles])
// Get global feed posts
useEffect(() => {
const start = () => {
const psf = 'wss://nostr-relay.psfoundation.info'
@@ -29,54 +64,25 @@ function Feed (props) {
relay.close()
})
pool.on('event', (relay, subId, ev) => {
pool.on('event', async (relay, subId, ev) => {
console.log('Received event:', ev)
// const profile = JSON.parse(ev.content)
setPosts(currentPosts => [...currentPosts, ev])
// fetch post profile and set it to profiles state
fetchProfile(ev.pubkey)
})
}
if (!loaded) {
start()
}
}, [bchWalletState, loaded])
}, [bchWalletState, loaded, fetchProfile])
return (
<Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}>
<div>
{posts.map((post, index) => (
<Card key={index} className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'>
<div className='d-flex align-items-center gap-3 mb-3'>
<div
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center'
style={{
width: '48px',
height: '48px',
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' />
</div>
<div className='flex-grow-1'>
<div className='fw-bold mb-1'>
{post.pubkey.slice(0, 8) + '...'}
</div>
<small className='text-muted'>
{`${post.pubkey.slice(0, 8)}...${post.pubkey.slice(-5)}`}
</small>
</div>
<small className='text-muted'>
{new Date(post.created_at * 1000).toLocaleString()}
</small>
</div>
<div className='mb-4'>
<div className='fs-5 ms-5 text-dark' style={{ lineHeight: '1.3' }}>
{post.content}
</div>
</div>
</Card.Body>
</Card>
<FeedCard key={index} post={post} appData={props.appData} profiles={profiles} />
))}
{!posts.length && loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
+231
View File
@@ -0,0 +1,231 @@
/*
This React components downloads the active Offers from the REST API and
displays them in a data table.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Table, Button, Spinner } from 'react-bootstrap'
import axios from 'axios'
import { DatatableWrapper, TableBody, TableHeader } from 'react-bs-datatable'
// Local libraries
import config from '../../../config'
import WaitingModal from '../../waiting-modal'
// Global variables and constants
const SERVER = `${config.dexServer}/`
const TABLE_HEADERS = [
{
prop: 'ticker',
title: 'Ticker',
isFilterable: true
},
{
prop: 'tokenId',
title: 'Token ID'
},
{
prop: 'buyOrSell',
title: 'Type'
},
{
prop: 'smallNostrEventId',
title: 'Event ID'
},
{
prop: 'numTokens',
title: 'Quantity'
},
{
prop: 'usdPrice',
title: 'Price (USD)'
},
{
prop: 'button',
title: 'Action'
}
]
function Offers (props) {
const [appData] = useState(props.appData)
const [offers, setOffers] = useState([])
// Modal state
const [showModal, setShowModal] = useState(false)
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [denyClose, setDenyClose] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Given a large string, it will return a string with the first and last
// four characters.
const cutString = (str) => {
try {
const subTxid = str.slice(0, 4)
const subTxid2 = str.slice(-4)
return `${subTxid}...${subTxid2}`
} catch (err) {
console.warn('Error in cutString() ', err)
}
}
// REST request to get data from avax-dex
const getOffers = async () => {
try {
const options = {
method: 'GET',
url: `${SERVER}offer/list/fungible/0`,
data: {}
}
const result = await axios.request(options)
return result.data
} catch (err) {
console.warn('Error in getOffers() ', err)
}
}
// Get Offer data and manipulate it for the sake of presentation.
const handleOffers = useCallback(async () => {
setIsLoading(true)
// Get raw offer data.
const offerRawData = await getOffers()
console.log('offerRawData: ', offerRawData)
if (!offerRawData || offerRawData.length === 0) {
setIsLoading(false)
setOffers([])
return
}
// Formatted Data
const formattedOffers = []
for (let i = 0; i < offerRawData.length; i++) {
const thisOffer = offerRawData[i]
// Get and format the token ID
const tokenId = thisOffer.tokenId
const smallTokenId = cutString(tokenId)
thisOffer.tokenId = (<a href={`https://token.fullstack.cash/?tokenid=${tokenId}`} target='_blank' rel='noreferrer'>{smallTokenId}</a>)
// Get and format the P2WDB ID
const nostrEventId = thisOffer.nostrEventId
const smallNostrEventId = cutString(nostrEventId)
thisOffer.smallNostrEventId = smallNostrEventId
thisOffer.button = (<Button text='Buy' variant='success' size='lg' id={nostrEventId} onClick={handleBuy}>Buy</Button>)
// thisOffer.p2wdbHash = (<a href={`https://p2wdb.fullstack.cash/entry/hash/${p2wdbHash}`} target='_blank' rel='noreferrer'>{smallP2wdbHash}</a>)
// Convert sats to BCH, and then calculate cost in USD.
const bchjs = appData.wallet.bchjs
const rateInSats = parseInt(thisOffer.rateInBaseUnit)
const bchCost = bchjs.BitcoinCash.toBitcoinCash(rateInSats)
const usdPrice = bchCost * appData.bchWalletState.bchUsdPrice
const priceStr = `$${usdPrice.toFixed(3)}`
thisOffer.usdPrice = priceStr
formattedOffers.push(thisOffer)
}
setOffers(formattedOffers)
setIsLoading(false)
}, [appData])
const handleBuy = async (event) => {
try {
console.log('Buy button clicked. Event: ', event)
const targetOfferEventId = event.target.id
console.log('targetOfferEventId: ', targetOfferEventId)
// Initialize modal
setShowModal(true)
setModalBody(['Generating Counter Offer...', '(This can take a couple minutes)'])
setHideSpinner(false)
setDenyClose(true)
const options = {
method: 'post',
url: `${SERVER}offer/take`,
data: {
nostrEventId: targetOfferEventId
}
}
const result = await axios.request(options)
const { eventId, noteId } = result.data
console.log(`Event Id : ${eventId}`)
console.log(`Note Id ${noteId}`)
// Add link to output
const newModalBody = []
newModalBody.push('Success!')
// newModalBody.push(<a href={`https://p2wdb.fullstack.cash/entry/hash/${p2wdbHash}`} target='_blank' rel='noreferrer'>P2WDB Entry</a>)
newModalBody.push('What happens next:')
newModalBody.push('The money has not yet left your wallet! It is still under your control.')
newModalBody.push('If the sellers node is online, they will accept the Counter Offer you just generated in a few minutes.')
newModalBody.push('If the tokens never show up, you can sweep the funds back into your wallet.')
setModalBody(newModalBody)
setHideSpinner(true)
setDenyClose(false)
} catch (error) {
console.warn('Error in handleBuy() ', error)
setModalBody(['Buy failed: ', error.message])
setHideSpinner(true)
setDenyClose(false)
}
}
useEffect(() => {
// Retrieve initial offer data
handleOffers()
// Get data and update the table periodically.
const interval = setInterval(() => {
handleOffers()
}, 30000)
// Cleanup interval on component unmount
return () => clearInterval(interval)
}, [handleOffers]) // Empty dependency array means this effect runs once on mount
const heading = 'Generating Counter Offer...'
return (
<>
{showModal && (
<WaitingModal
heading={heading}
body={modalBody}
hideSpinner={hideSpinner}
denyClose={denyClose}
/>
)}
<Container>
<Row>
<Col className='text-break' style={{ textAlign: 'center' }}>
{!isLoading && (
<DatatableWrapper body={offers} headers={TABLE_HEADERS}>
<Table>
<TableHeader />
<TableBody />
</Table>
</DatatableWrapper>
)}
</Col>
</Row>
{isLoading && (
<Row>
<Col className='text-break' style={{ textAlign: 'center' }}>
<Spinner animation='border' />
</Col>
</Row>
)}
</Container>
</>
)
}
export default Offers
+36 -22
View File
@@ -43,9 +43,43 @@ function NavMenu (props) {
to='/nfts-for-sale'
onClick={handleClickEvent}
>
NFTs for Sale
NFTs
</NavLink>
<NavLink
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='/global-feed'
onClick={handleClickEvent}
>
Global Feed
</NavLink>
<NavLink
className={currentPath === '/nostr-post' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-post'
onClick={handleClickEvent}
>
Nostr Post
</NavLink>
<NavLink
className={currentPath === '/nostr-read' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-read'
onClick={handleClickEvent}
>
Nostr Profile
</NavLink>
<hr />
<NavLink
className={(currentPath === '/bch') ? 'nav-link-active' : 'nav-link-inactive'}
to='/bch'
@@ -100,27 +134,7 @@ function NavMenu (props) {
>
Configuration
</NavLink>
<NavLink
className={currentPath === '/nostr-post' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-post'
onClick={handleClickEvent}
>
Nostr Post
</NavLink>
<NavLink
className={currentPath === '/nostr-read' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-read'
onClick={handleClickEvent}
>
Nostr Profile
</NavLink>
<NavLink
className={currentPath === '/global-feed' ? 'nav-link-active' : 'nav-link-inactive'}
to='/global-feed'
onClick={handleClickEvent}
>
Global Feed
</NavLink>
</Nav>
</Navbar.Collapse>
</Navbar>