Compare commits

..
21 Commits
Author SHA1 Message Date
Chris Troutner 907bb7e468 Merge pull request #26 from Permissionless-Software-Foundation/ct-unstable
fix(nostr): Tagging posts with BCH address
2025-06-30 08:12:30 -07:00
Chris Troutner 7eb5ff7821 fix(nostr): Tagging posts with BCH address 2025-06-30 08:11:11 -07:00
Chris Troutner 8a982d2ef2 Merge pull request #25 from Permissionless-Software-Foundation/dh-user-data-page
fix(user-data): Reconfigured user data page
2025-06-26 06:00:58 -07:00
Daniel Gonzalez 24bb18c77c fix(user-data): Reconfigured user data page 2025-06-25 18:58:24 -04:00
Chris Troutner 913745ba9f Merge pull request #24 from Permissionless-Software-Foundation/dh-img-carousel
feat(nft): Added image carousel
2025-06-24 08:04:57 -07:00
Chris Troutner f24c199dd6 Merge branch 'master' into dh-img-carousel 2025-06-24 08:03:45 -07:00
Chris Troutner 0da9d4c567 Merge pull request #23 from Permissionless-Software-Foundation/dh-local-storage
feat(cache): Persist NFT cache to Local Storage
2025-06-24 08:02:31 -07:00
Daniel Gonzalez e05948aa32 feat(nft): Added image carousel 2025-06-23 20:20:24 -04:00
Daniel Gonzalez 3cf69cbd95 feat(cache): Persist NFT cache to Local Storage 2025-06-23 12:08:22 -04:00
Chris Troutner 45b28c168f Merge pull request #22 from Permissionless-Software-Foundation/dh-nostr-formating
feat(nostr): Added Nostr post formatting
2025-06-19 17:42:56 -07:00
Chris Troutner 3d622e9d99 Merge branch 'master' into dh-nostr-formating 2025-06-19 17:41:07 -07:00
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 a89d4c21cc feat(nostr): Added Nostr post formatting 2025-06-18 20:41:09 -04: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 db179b3c6c Merge branch 'master' into ct-unstable 2025-06-09 17:11:56 -07:00
Chris Troutner 33a3a21b68 Comments 2025-06-09 17:09:38 -07:00
15 changed files with 22421 additions and 15497 deletions
+22090 -15346
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -15,6 +15,7 @@
"bch-token-sweep": "2.2.1",
"bootstrap": "5.2.0",
"crypto-browserify": "3.12.1",
"marked": "15.0.12",
"nostr-tools": "2.10.4",
"p2wdb": "2.4.1",
"process": "0.11.10",
+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,
+1 -1
View File
@@ -50,7 +50,7 @@ function AppBody (props) {
<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='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
<Route path='/offers' element={<Offers appData={appData} />} />
</Routes>
{/** Show in all paths except the servers view */}
@@ -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)
@@ -102,7 +102,7 @@ function InfoButton (props) {
<Col xs={4}><b>User Data</b>:</Col>
<Col xs={8}>
<a
href={`/user-data/${mutableDataCid}`}
href={`/user-data/${props.token.tokenId}`}
target='_blank'
rel='noopener noreferrer'
className='btn btn-link p-0'
@@ -0,0 +1,100 @@
/**
* Component for reading global nostr posts
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import * as nip19 from 'nostr-tools/nip19'
import NostrFormat from '../nostr-format'
function FeedCard (props) {
const { post, appData, profiles } = props
const [profile, setProfile] = useState(profiles[post.pubkey])
const [npub, setNpub] = useState('')
const [isClicked, setIsClicked] = useState(false)
// Get npub from pubkey
useEffect(() => {
const npub = nip19.npubEncode(post.pubkey)
setNpub(npub)
}, [post])
// Verify if pubkey profile exists into the profiles state
useEffect(() => {
if (profiles[post.pubkey]) {
setProfile(profiles[post.pubkey])
}
}, [profiles, post])
// copy to clipboard
const copyToClipboard = useCallback((value) => {
setIsClicked(true)
appData.appUtil.copyToClipboard(value)
setTimeout(() => setIsClicked(false), 200)
}, [appData])
// get short npub
const getShortNpub = useCallback((npub) => {
return npub.slice(0, 8) + '...' + npub.slice(-5)
}, [])
return (
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'>
<div className='d-flex align-items-center gap-3 mb-3'>
<div
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center'
style={{
width: '48px',
height: '48px',
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' />
</div>
<div className='flex-grow-1'>
<div className='fw-bold mb-1'>
{profile && profile.name && <span>{profile.name}</span>}
{!profile?.name && (
<span>
{post.pubkey.slice(0, 8) + '...'}
<Spinner animation='border' size='sm' className='ms-2' />
</span>
)}
</div>
{npub && (
<small className='text-muted'>
<span
title='Copy to clipboard'
style={{
cursor: 'pointer',
transform: isClicked ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease',
display: 'inline-block'
}}
onClick={() => copyToClipboard(npub)}
>
{getShortNpub(npub)}
</span>
</small>
)}
</div>
<small className='text-muted'>
{new Date(post.created_at * 1000).toLocaleString()}
</small>
</div>
<div className='mb-4'>
<div className='fs-5 ms-5 text-dark' style={{ lineHeight: '1.3' }}>
<NostrFormat content={post.content} />
</div>
</div>
</Card.Body>
</Card>
)
}
export default FeedCard
@@ -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'>
@@ -0,0 +1,33 @@
/**
* Parse nostr content to html , support markdown , links , line breaks
*/
import React from 'react'
import { marked } from 'marked' // https://www.npmjs.com/package/marked/v/0.3.9
const NostrFormat = ({ content }) => {
const formatContent = (content) => {
if (!content) return ''
// create marked renderer
const renderer = new marked.Renderer()
// customize link renderer to open in new tab
renderer.link = function ({ href, text }) {
return `<a href="${href}" target="_blank" >${text}</a>`
}
// set marked options
marked.setOptions({
renderer,
breaks: true
})
// parse content to html
const html = marked.parse(content)
return html
}
return (
<div dangerouslySetInnerHTML={{ __html: formatContent(content) }} />
)
}
export default NostrFormat
@@ -47,11 +47,14 @@ function PublicPost (props) {
// Relay list
const psf = 'wss://nostr-relay.psfoundation.info'
// BCH address of the user.
const bchAddr = bchWalletState.address
// Generate a post.
const eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [['t', 'slpdex-socialmedia']],
tags: [['t', 'slpdex-socialmedia'], ['u', bchAddr]],
content: formData.content
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
@@ -7,7 +7,7 @@ import { Container, Button } from 'react-bootstrap'
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr'
function ProfileRead (props) {
@@ -74,7 +74,9 @@ function ProfileRead (props) {
</span>
<CopyOnClick walletProp='npub' appData={props.appData} value={bchWalletState.nostrKeyPair.npub} />
</div>
<div className='fs-6 text-secondary'>{post.about}</div>
<div className='fs-6 text-secondary'>
<NostrFormat content={post.about} />
</div>
</div>
<div className='d-flex gap-2 align-items-center flex-wrap justify-content-center'>
<Button
@@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react'
import { Container, Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr'
function PublicRead (props) {
@@ -72,7 +72,7 @@ function PublicRead (props) {
</div>
<div className='mb-4'>
<div className='fs-5 ms-5 text-dark' style={{ lineHeight: '1.3' }}>
{post.content}
<NostrFormat content={post.content} />
</div>
</div>
</Card.Body>
+48 -48
View File
@@ -85,6 +85,53 @@ function Offers (props) {
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 () => {
@@ -129,54 +176,7 @@ function Offers (props) {
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)
}
}
}, [appData, handleBuy])
useEffect(() => {
// Retrieve initial offer data
+77 -48
View File
@@ -5,25 +5,34 @@
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Container, Row, Col, Tabs, Tab, Spinner } from 'react-bootstrap'
import { Container, Row, Col, Spinner, Carousel } from 'react-bootstrap'
import ReactMarkdown from 'react-markdown'
function UserDataReview (props) {
const appData = props.appData
const [media, setMedia] = useState([])
const [markdown, setMarkdown] = useState('')
const [activeTab, setActiveTab] = useState('media')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [tokenData, setTokenData] = useState(null)
// Get the id parameter from the URL
const { cid } = useParams()
const { tokenId } = useParams()
useEffect(() => {
const loadMedia = async () => {
setLoading(true)
const loadData = async () => {
try {
const tokenData = await appData.wallet.getTokenData(tokenId)
setTokenData(tokenData)
setLoading(true)
if (!tokenData.mutableData) throw new Error('Mutable data not found')
const cid = parseCid(tokenData.mutableData)
const { json } = await appData.wallet.cid2json({ cid })
const userDataString = json.userData
if (userDataString) {
const userData = JSON.parse(userDataString)
setMedia(userData.media)
@@ -34,60 +43,80 @@ function UserDataReview (props) {
}
setLoading(false)
}
loadMedia()
}, [cid, appData.wallet])
loadData()
}, [tokenId, appData.wallet])
// 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
}
return (
<Container>
<Row>
<Col>
{/** Error message */}
{error && <p className='text-danger'>{error}</p>}
{loading
? (
{/** Loading spinner */}
{loading &&
(
<div className='text-center my-5'>
<Spinner animation='border' role='status' variant='primary'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
)
: (
<Tabs
activeKey={activeTab}
onSelect={(k) => setActiveTab(k)}
className='mb-4'
>
<Tab eventKey='media' title='Media'>
{media && media.length > 0
? (
<Row className='mt-3'>
{media.map((item, index) => (
<Col key={index} xs={12} sm={6} md={4} lg={4} className='mb-3'>
<img
src={item.url}
alt={`Review media ${index + 1}`}
style={{ width: '100%', height: '250px', objectFit: 'cover', borderRadius: '8px' }}
/>
</Col>
))}
</Row>
)
: (
<p className='mt-3'>No media content available</p>
)}
</Tab>
<Tab eventKey='markdown' title='Content'>
{markdown
? (
<div className='markdown-content mt-3'>
<ReactMarkdown>{markdown}</ReactMarkdown>
</div>
)
: (
<p className='mt-3'>No content available</p>
)}
</Tab>
</Tabs>
)}
)}
{/** User data */}
{!loading && !error && (
<div className='text-center'>
{/** Name */}
<h1>{tokenData.genesisData.name}</h1>
{/** Media Content Carousel */}
{media && media.length > 0
? (
<div className='my-5 '>
<Carousel>
{media.map((item, index) => (
<Carousel.Item key={index}>
<img
className='d-block w-100'
src={item.url}
alt={`Review media ${index + 1}`}
style={{ height: '400px', objectFit: 'contain' }}
/>
<Carousel.Caption>
<p>Image {index + 1} of {media.length}</p>
</Carousel.Caption>
</Carousel.Item>
))}
</Carousel>
<style>{'.carousel-control-next,.carousel-control-prev, .carousel-indicators {filter: invert(100%); } '}
</style>
</div>
)
: (
<p className='mt-3'>No media content available</p>
)}
{/** Markdown Content */}
{markdown
? (
<div className='markdown-content my-5 text-center'>
<ReactMarkdown>{markdown}</ReactMarkdown>
</div>
)
: (
<p className='mt-3'>No content available</p>
)}
</div>
)}
</Col>
</Row>
</Container>
+4 -2
View File
@@ -13,7 +13,8 @@ function useAppState () {
ssr: true,
defaultValue: {
serverUrl: 'https://free-bch.fullstack.cash' // Default server
}
},
nftData: {}
})
console.log('lsState: ', lsState)
@@ -38,7 +39,7 @@ function useAppState () {
const [denyClose, setDenyClose] = useState(false)
// NFTs for sale stored data to improve performance
const [nftForSaleCacheData, setNftForSaleCacheData] = useState({})
const [nftForSaleCacheData, setNftForSaleCacheData] = useState(lsState.nftData || {})
// The wallet state makes this a true progressive web app (PWA). As
// balances, UTXOs, and tokens are retrieved, this state is updated.
@@ -107,6 +108,7 @@ function useAppState () {
const newCacheData = Object.assign({}, cacheData, data) // Merge the new data with the cache data
allCacheData[tokenId] = newCacheData // Update the cache data
setNftForSaleCacheData(allCacheData) // Update the state
updateLocalStorage({ nftData: allCacheData }) // Update the local storage
}
return {