Compare commits

..
8 Commits
Author SHA1 Message Date
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
Daniel Gonzalez 76dcd45d16 feat(nostr): Created Nostr Global Feed page 2025-06-09 19:32:55 -04:00
Chris Troutner acb39ceff8 Merge pull request #15 from Permissionless-Software-Foundation/dh-cache-data
feat(cache): caching mutable data
2025-06-06 20:38:34 -07:00
Daniel Gonzalez 63b7bad1d1 Fixed merge conflicts 2025-06-06 21:24:09 -04:00
Chris Troutner c140255a26 Merge pull request #16 from Permissionless-Software-Foundation/dh-product-modal
feat(info): Added product info modal
2025-06-06 11:23:23 -07:00
Daniel Gonzalez 947cb43535 linting 2025-06-06 12:53:18 -04:00
Daniel Gonzalez 93980ed703 feat(info): Added product info modal 2025-06-06 12:51:44 -04:00
Daniel Gonzalez 36c81e7ce9 feat(cache): caching mutable data 2025-06-03 19:34:57 -04:00
11 changed files with 1533 additions and 17 deletions
+1177
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -23,6 +23,7 @@
"react": "19.0.0",
"react-bootstrap": "2.10.7",
"react-dom": "19.0.0",
"react-markdown": "10.1.0",
"react-router-dom": "7.1.3",
"react-scripts": "5.0.1",
"stream-browserify": "3.0.0",
+4 -2
View File
@@ -25,7 +25,8 @@ 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'
function AppBody (props) {
// Dependency injection through props
const appData = props.appData
@@ -47,7 +48,8 @@ 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} />} />
</Routes>
{/** Show in all paths except the servers view */}
{/* {appData.currentPath !== '/servers' && <SelectServerButton linkTo='/servers' appData={appData} />} */}
+50 -10
View File
@@ -98,7 +98,7 @@ function NftsForSale (props) {
// 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
@@ -115,7 +115,7 @@ function NftsForSale (props) {
}, [appData])
// Fetch mutable data if it exist and get the token icon url
const fetchTokenInfo = useCallback(async (token) => {
const fetchTokenMutableData = useCallback(async (token) => {
try {
// Get the token data
const tokenData = token.tokenData
@@ -135,15 +135,16 @@ function NftsForSale (props) {
if (json.fullSizedUrl && json.fullSizedUrl.includes('http')) {
iconUrl = json.fullSizedUrl
}
const userData = json.userData
// Return icon url
return iconUrl
return { iconUrl, userData }
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadTokenIcons = useCallback(async (tokens) => {
const lazyLoadMutableData = useCallback(async (tokens) => {
try {
setIconsAreLoaded(false)
// map each token and fetch the icon url
@@ -154,11 +155,12 @@ function NftsForSale (props) {
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const iconUrl = await fetchTokenInfo(thisToken)
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.
@@ -169,7 +171,39 @@ function NftsForSale (props) {
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenInfo])
}, [fetchTokenMutableData, appData])
// Check if exiist token data 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 exiist token data 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 () => {
@@ -178,17 +212,23 @@ function NftsForSale (props) {
// 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
// Load tokens data for any updates to the token data
await lazyLoadTokenData(offers)
// Load tokens icons
await lazyLoadTokenIcons(offers)
// Load tokens icons from mutable data
await lazyLoadMutableData(offers)
// Update cached data with the latest retrieved data
await updateNFTCachedData(offers)
setIsLoading(false)
} catch (err) {
setIsLoading(false)
console.error('Error loading NFT offers:', err)
}
}, [lazyLoadTokenData, lazyLoadTokenIcons, getNftOffers])
}, [lazyLoadTokenData, lazyLoadMutableData, getNftOffers, reviewNftCachedData, updateNFTCachedData])
// Effect to load NFTs on component mount
useEffect(() => {
@@ -6,11 +6,12 @@
*/
// Global npm libraries
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import { Button, Modal, Container, Row, Col } from 'react-bootstrap'
function InfoButton (props) {
const [show, setShow] = useState(false)
const [mutableDataCid, setMutableDataCid] = useState(null)
const handleClose = () => {
console.log('handleClose()')
@@ -22,6 +23,32 @@ function InfoButton (props) {
console.log('handleOpen()')
setShow(true)
}
// 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
}
// Get token user data if it exists and verify if it contains media or markdown
useEffect(() => {
try {
const userDataStr = props.token.tokenData.userData
if (userDataStr) {
const userData = JSON.parse(userDataStr)
// If user data contains media or markdown, set the mutable data cid
if (userData?.media || userData?.markdown) {
setMutableDataCid(parseCid(props.token.tokenData.mutableData))
}
}
} catch (error) {
// Do nothing
}
}, [props.token, show])
// Replace with dummy button until token data is loaded.
if (!props.token.tokenData) {
@@ -70,6 +97,22 @@ function InfoButton (props) {
<Col xs={8}>{props.token.tokenType}</Col>
</Row>
{mutableDataCid && (
<Row>
<Col xs={4}><b>User Data</b>:</Col>
<Col xs={8}>
<a
href={`/user-data/${mutableDataCid}`}
target='_blank'
rel='noopener noreferrer'
className='btn btn-link p-0'
>
View User Data
</a>
</Col>
</Row>
)}
</Container>
</Modal.Body>
<Modal.Footer />
@@ -0,0 +1,98 @@
/**
* 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 { RelayPool } from 'nostr'
function Feed (props) {
const { bchWalletState } = props.appData
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
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', (relay, subId, ev) => {
console.log('Received event:', ev)
// const profile = JSON.parse(ev.content)
setPosts(currentPosts => [...currentPosts, ev])
})
}
if (!loaded) {
start()
}
}, [bchWalletState, loaded])
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>
))}
{!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>
)
@@ -0,0 +1,97 @@
/*
Search in the provided cid of a mutable data, get the data from the user data and show it
*/
// 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 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)
// Get the id parameter from the URL
const { cid } = useParams()
useEffect(() => {
const loadMedia = async () => {
setLoading(true)
try {
const { json } = await appData.wallet.cid2json({ cid })
const userDataString = json.userData
if (userDataString) {
const userData = JSON.parse(userDataString)
setMedia(userData.media)
setMarkdown(userData.markdown)
}
} catch (error) {
setError(error.message)
}
setLoading(false)
}
loadMedia()
}, [cid, appData.wallet])
return (
<Container>
<Row>
<Col>
{error && <p className='text-danger'>{error}</p>}
{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>
)}
</Col>
</Row>
</Container>
)
}
export default UserDataReview
+7
View File
@@ -114,6 +114,13 @@ function NavMenu (props) {
>
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>
+17 -1
View File
@@ -37,6 +37,9 @@ function useAppState () {
const [hideSpinner, setHideSpinner] = useState(false)
const [denyClose, setDenyClose] = useState(false)
// NFTs for sale stored data to improve performance
const [nftForSaleCacheData, setNftForSaleCacheData] = useState({})
// The wallet state makes this a true progressive web app (PWA). As
// balances, UTXOs, and tokens are retrieved, this state is updated.
// properties are enumerated here for the purpose of documentation.
@@ -96,6 +99,16 @@ function useAppState () {
}
}
// Update NFT for sale tokens data
function updateNFTCachedData (tokenId, data) {
const allCacheData = nftForSaleCacheData // Get all cache data
const cacheData = allCacheData[tokenId] || {} // Get cache data for the tokenId
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
}
return {
serverUrl,
setServerUrl,
@@ -131,7 +144,10 @@ function useAppState () {
dexLib,
setDexLib,
nostr,
setNostr
setNostr,
nftForSaleCacheData,
setNftForSaleCacheData,
updateNFTCachedData
}
}