Compare commits

...
15 Commits
Author SHA1 Message Date
Chris Troutner 29ce96ae92 Merge pull request #21 from Permissionless-Software-Foundation/dh-fungible-buy-btn
fix(buy): Fixed fungible buy button
2025-06-19 17:38:25 -07:00
Daniel Gonzalez 561ffbaaad fix(buy): Fixed fungible buy button 2025-06-18 13:45:05 -04:00
Chris Troutner 2141f919dd Merge pull request #20 from Permissionless-Software-Foundation/dh-global-feed-profiles
feat(nostr): Display profile information in Global Feed
2025-06-14 08:19:57 -07:00
Daniel Gonzalez 3087533aff feat(nostr): Display profile information in Global Feed 2025-06-13 14:21:56 -04:00
Chris Troutner 81c367dad8 Merge pull request #19 from Permissionless-Software-Foundation/ct-unstable
Ct unstable
2025-06-11 18:41:36 -07:00
Chris Troutner 5a79af5e20 fix(package-lock): fixing package-lock 2025-06-11 18:40:03 -07:00
Chris Troutner 3eb322e9c8 Merge branch 'master' into ct-unstable 2025-06-11 18:25:13 -07:00
Chris Troutner da3d24abaa Merge pull request #18 from Permissionless-Software-Foundation/dh-fungible-tokens
feat(tokens): Added support for fungible tokens
2025-06-11 18:24:58 -07:00
Chris Troutner ae2ba02adb fix(nav): Organizing nav menu 2025-06-11 18:22:19 -07:00
Daniel Gonzalez 7c08a281b5 linting 2025-06-10 19:49:28 -04:00
Daniel Gonzalez e7bbd7f7b4 feat(tokens): Added support for fungible tokens 2025-06-10 19:46:37 -04:00
Chris Troutner db179b3c6c Merge branch 'master' into ct-unstable 2025-06-09 17:11:56 -07:00
Chris Troutner a93241f880 Merge pull request #17 from Permissionless-Software-Foundation/dh-global-feed
feat(nostr): Created Nostr Global Feed page
2025-06-09 17:11:38 -07:00
Chris Troutner 33a3a21b68 Comments 2025-06-09 17:09:38 -07:00
Daniel Gonzalez 76dcd45d16 feat(nostr): Created Nostr Global Feed page 2025-06-09 19:32:55 -04:00
11 changed files with 6149 additions and 2059 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,
+4
View File
@@ -25,7 +25,9 @@ import SignMessage from './sign/index.js'
import ServerSelectView from './configuration/select-server-view'
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
@@ -47,7 +49,9 @@ function AppBody (props) {
<Route path='/configuration' element={<ServerSelectView appData={appData} />} />
<Route path='/nostr-post' element={<NostrPost appData={appData} />} />
<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
@@ -0,0 +1,104 @@
/**
* Component for reading global nostr posts
*/
// Global npm libraries
import React, { useEffect, useState, useCallback } from 'react'
import { Container, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr'
import FeedCard from './feed-card'
function Feed (props) {
const { appData } = props
const { bchWalletState } = appData
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
const [profiles, setProfiles] = useState({})
// function to fetch profile and set it to profiles state
const fetchProfile = useCallback((pubkey) => {
// no fetch profile again if it exist
if (profiles[pubkey]) {
console.log('profile exists', profiles[pubkey])
return profiles[pubkey]
}
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubkey] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
const newProfiles = { ...profiles }
newProfiles[pubkey] = profile
setProfiles(newProfiles)
} catch (error) {
console.log('Error parsing profile', error)
}
})
}, [profiles])
// Get global feed posts
useEffect(() => {
const start = () => {
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('REQ', { limit: 10, kinds: [1], '#t': ['slpdex-socialmedia'] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
setLoaded(true)
relay.close()
})
pool.on('event', async (relay, subId, ev) => {
console.log('Received event:', ev)
setPosts(currentPosts => [...currentPosts, ev])
// fetch post profile and set it to profiles state
fetchProfile(ev.pubkey)
})
}
if (!loaded) {
start()
}
}, [bchWalletState, loaded, fetchProfile])
return (
<Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}>
<div>
{posts.map((post, index) => (
<FeedCard key={index} post={post} appData={props.appData} profiles={profiles} />
))}
{!posts.length && loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<i className='bi bi-inbox-fill fs-1 mb-3 d-block opacity-50' />
<div>No posts found</div>
</div>
)}
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
</div>
)}
</div>
</Container>
)
}
export default Feed
@@ -0,0 +1,31 @@
/*
Component for reading the nostr global feed.
*/
// Global npm libraries
import React from 'react'
import { Container } from 'react-bootstrap'
import Feed from './feed'
function GlobalFeed (props) {
const { appData } = props
return (
<>
<Container>
<h2 style={{
textAlign: 'center',
margin: '20px 0 50px 0',
padding: '10px',
borderBottom: '2px solid #ccc'
}}
>
Global Feed
</h2>
<Feed appData={appData} />
</Container>
</>
)
}
export default GlobalFeed
@@ -3,7 +3,7 @@
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Card } from 'react-bootstrap'
import { Container, Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
@@ -28,6 +28,7 @@ function PublicRead (props) {
pool.on('eose', relay => {
console.log('Closing Relay')
setLoaded(true)
relay.close()
})
@@ -35,8 +36,6 @@ function PublicRead (props) {
console.log('Received event:', ev)
setPosts(currentPosts => [...currentPosts, ev])
})
setLoaded(true)
}
if (!loaded) {
@@ -85,6 +84,11 @@ function PublicRead (props) {
<div>No posts found</div>
</div>
)}
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
</div>
)}
</div>
</Container>
)
+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)
}
}
const handleBuy = useCallback(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)
// Generate a counter offer.
const bchDexLib = appData.dexLib
const { offerData, partialHex } = await bchDexLib.take.takeOffer(
targetOfferEventId
)
// Upload the counter offer to Nostr.
const nostr = appData.nostr
const { eventId, noteId } = await nostr.testNostrUpload({
offerData,
partialHex
})
console.log('eventId: ', eventId)
console.log('noteId: ', noteId)
// update output modal
const newModalBody = []
newModalBody.push('Success!')
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)
}
}, [appData]) // Dependencies: appData since it's used inside the callback
// 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, handleBuy])
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 -15
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,20 +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>
</Nav>
</Navbar.Collapse>
</Navbar>