Compare commits

...
13 Commits
Author SHA1 Message Date
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
Chris Troutner 10ef73f542 Merge pull request #38 from Permissionless-Software-Foundation/dh-extra-data
feat(nostr): Implemented extra profile data
2025-07-25 06:45:44 -07:00
Chris Troutner 0ad9147e25 Merge branch 'master' into dh-extra-data 2025-07-25 06:43:08 -07:00
Chris Troutner 53c050a856 Merge pull request #37 from Permissionless-Software-Foundation/dh-feed-tabs
feat(nostr): Added tabs to Feeds page
2025-07-25 06:37:40 -07:00
Daniel Gonzalez 323f9e4b0a feat(nostr): Implemented extra profile data 2025-07-22 20:26:51 -04:00
Daniel Gonzalez ad79d61dc1 feat(nostr): Added tabs to Feeds page 2025-07-22 12:29:21 -04:00
16 changed files with 876 additions and 385 deletions
+36 -3
View File
@@ -14,6 +14,7 @@ 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'
const sigleViewPaths = ['/profile']
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 +31,44 @@ 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)
}
// 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 +145,7 @@ function App (props) {
} }
} }
asyncEffect() asyncEffect()
}, [appData, addToModal]) }, [appData, addToModal, isSignleView])
return ( return (
<> <>
+2 -2
View File
@@ -25,7 +25,7 @@ import SignMessage from './sign/index.js'
import ServerSelectView from './configuration/select-server-view' import ServerSelectView from './configuration/select-server-view'
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 GlobalFeed from './nostr/global-feed/index.js' import Feeds from './nostr/feeds/index.js'
import ContentCreators from './nostr/content-creators/index.js' import ContentCreators from './nostr/content-creators/index.js'
import UserDataReview from './user-data-review' import UserDataReview from './user-data-review'
import Offers from './offers' import Offers from './offers'
@@ -50,7 +50,7 @@ function AppBody (props) {
<Route path='/configuration' element={<ServerSelectView appData={appData} />} /> <Route path='/configuration' element={<ServerSelectView 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='/global-feed' element={<GlobalFeed appData={appData} />} /> <Route path='/feeds' element={<Feeds appData={appData} />} />
<Route path='/content-creators' element={<ContentCreators appData={appData} />} /> <Route path='/content-creators' element={<ContentCreators appData={appData} />} />
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} /> <Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
<Route path='/offers' element={<Offers appData={appData} />} /> <Route path='/offers' element={<Offers appData={appData} />} />
@@ -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'>
@@ -0,0 +1,261 @@
/**
* Component for displaying 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, 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 NostrFormat from '../nostr-format'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import { RelayPool } from 'nostr'
function FeedCard (props) {
const { post, appData, profiles } = props
const { nostrKeyPair } = appData.bchWalletState
const [profile, setProfile] = useState(profiles[post.pubkey])
const [npub, setNpub] = useState('')
const [isClicked, setIsClicked] = useState(false)
const [isLiked, setIsLiked] = useState(false)
const [likesCount, setLikesCount] = useState(null)
const [likesFetched, setLikesFetched] = useState(false)
// function to fetch a post likes reaction
const handleLikes = useCallback(async (post, userPubKey) => {
try {
const psf = 'wss://nostr-relay.psfoundation.info'
const res = await new Promise((resolve) => {
let likesCnt = 0
let userLikedCnt = 0
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { kinds: [7], '#e': [post.id] })
})
pool.on('eose', relay => {
relay.close()
resolve({
count: likesCnt,
userLiked: userLikedCnt > 0
})
})
pool.on('event', (relay, subId, ev) => {
try {
// Count likes
if (ev.content === '+') {
likesCnt++
// Count user likes
if (ev.pubkey === userPubKey) {
userLikedCnt++
}
}
// Count dislikes
if (ev.content === '-') {
likesCnt--
// Count user dislikes.
if (ev.pubkey === userPubKey) {
userLikedCnt--
}
}
} catch (error) {
// skip error
}
})
})
setLikesCount(res.count)
setIsLiked(res.userLiked)
setLikesFetched(true)
} catch (error) {
console.warn(error)
}
}, [])
// Get npub from pubkey
useEffect(() => {
const npub = nip19.npubEncode(post.pubkey)
setNpub(npub)
// Handle post likes
if (!likesFetched && nostrKeyPair.pubHex) {
handleLikes(post, nostrKeyPair.pubHex)
}
}, [post, nostrKeyPair, handleLikes, likesFetched])
// 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)
}, [])
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)
// Relay list
const psf = 'wss://nostr-relay.psfoundation.info'
// 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, psf],
['p', psf, nostrKeyPair.pubHex]
],
content
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Connect to a relay.
const relay = await Relay.connect(psf)
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()
await handleLikes(post, nostrKeyPair.pubHex)
setLikesFetched(true)
} catch (error) {
console.warn(error)
setLikesFetched(true)
}
}
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) + '...'}
{!profile?.loaded && <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>
<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>
)
}
export default FeedCard
@@ -0,0 +1,30 @@
/**
* Component for displaying global nostr posts
*/
// Global npm libraries
import React from 'react'
import { Container } from 'react-bootstrap'
import FeedCard from './feed-card'
function Feed (props) {
const { posts, profiles } = props
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 && (
<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>
)}
</div>
</Container>
)
}
export default Feed
@@ -0,0 +1,82 @@
/**
* Component for displaying following nostr posts
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr'
import FeedCard from './feed-card'
function Following (props) {
const { appData, posts, profiles } = props
const [followingPosts, setFollowingPosts] = useState([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
const getFollowingPosts = async () => {
const followingList = await new Promise((resolve, reject) => {
let list = []
const { nostrKeyPair } = appData.bchWalletState
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
let filteredPosts = []
for (let i = 0; i < followingList.length; i++) {
const following = followingList[i]
const filtered = posts.filter(post => {
return following.includes(post.pubkey)
})
filteredPosts = [...filteredPosts, ...filtered]
}
// Sort filtered posts by created_at
filteredPosts.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
setFollowingPosts(filteredPosts)
setLoaded(true)
}
getFollowingPosts()
}, [appData, posts])
return (
<Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}>
<div>
{loaded && followingPosts.map((post, index) => (
<FeedCard key={index} post={post} appData={props.appData} profiles={profiles} />
))}
{loaded && !followingPosts.length && (
<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 Following
@@ -0,0 +1,152 @@
/*
Component for reading the nostr feeds.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Nav, Tab, Spinner } from 'react-bootstrap'
import Feed from './feed'
import Following from './following'
import { RelayPool } from 'nostr'
function Feeds (props) {
const { appData } = props
const [activeTab, setActiveTab] = useState(appData.lastFeedTab)
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
setProfiles(currentProfiles => {
if (currentProfiles[pubkey]) {
return currentProfiles
}
return currentProfiles
})
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 => {
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 => {
const newProfiles = { ...currentProfiles }
newProfiles[pubkey] = profile
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) {
start()
}
}, [bchWalletState, loaded, fetchProfile])
const onChangeTab = (tab) => {
setActiveTab(tab)
appData.setLastFeedTab(tab)
appData.updateLocalStorage({ lastFeedTab: tab })
}
return (
<>
<Container>
<h2 style={{
textAlign: 'center',
margin: '20px 0 30px 0',
padding: '10px',
borderBottom: '2px solid #ccc'
}}
>
Feeds
</h2>
{loaded && (
<Tab.Container activeKey={activeTab} onSelect={onChangeTab}>
<Nav variant='tabs' className='mb-4'>
<Nav.Item>
<Nav.Link eventKey='feed' className={activeTab === 'feed' ? 'active' : ''}>
Feeds
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey='following' className={activeTab === 'following' ? 'active' : ''}>
Following
</Nav.Link>
</Nav.Item>
</Nav>
<Tab.Content>
<Tab.Pane eventKey='feed'>
<Feed appData={appData} posts={posts} profiles={profiles} />
</Tab.Pane>
<Tab.Pane eventKey='following'>
<Following appData={appData} posts={posts} profiles={profiles} />
</Tab.Pane>
</Tab.Content>
</Tab.Container>
)}
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
</div>
)}
</Container>
</>
)
}
export default Feeds
@@ -1,100 +0,0 @@
/**
* 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
@@ -1,104 +0,0 @@
/**
* 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
@@ -1,31 +0,0 @@
/*
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
@@ -6,24 +6,22 @@ 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 } from '@fortawesome/free-solid-svg-icons' import { faUser, faGlobe } from '@fortawesome/free-solid-svg-icons'
import NostrFormat from '../nostr-format' import NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr' import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19' import * as nip19 from 'nostr-tools/nip19'
function ProfileRead (props) { function ProfileRead (props) {
const { npub } = props const { npub } = props
const { bchWalletState } = props.appData
console.log('npub prop', npub)
const { setProfile } = props const { setProfile } = props
const [post, setPost] = useState({}) const [post, setPost] = useState({})
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [imageError, setImageError] = useState({ picture: false, banner: false })
useEffect(() => { useEffect(() => {
const start = () => { const start = () => {
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 psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf]) const pool = RelayPool([psf])
@@ -39,10 +37,14 @@ function ProfileRead (props) {
pool.on('event', (relay, subId, ev) => { pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev) console.log('Received event:', ev)
const profile = JSON.parse(ev.content) try {
console.log('Profile:', profile) const profile = JSON.parse(ev.content)
setPost(profile) console.log('Profile:', profile)
setProfile(profile) setPost(profile)
setProfile(profile)
} catch (error) {
console.error('Error parsing profile:', error)
}
}) })
setLoaded(true) setLoaded(true)
@@ -51,40 +53,101 @@ function ProfileRead (props) {
if (!loaded && npub) { if (!loaded && npub) {
start() start()
} }
}, [bchWalletState, loaded, setProfile, npub]) }, [loaded, setProfile, npub])
const handleImageError = (type) => {
setImageError(prev => ({ ...prev, [type]: true }))
}
const handleImageLoad = (type) => {
setImageError(prev => ({ ...prev, [type]: false }))
}
return ( return (
<Container> <Container>
<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'> {/* Banner Section */}
<div style={{ width: '100px', height: '100px' }} className='mb-3 mb-md-0'> {post.banner && !imageError.banner && (
<div <div className='position-relative'>
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center' <img
style={{ src={post.banner}
width: '100%', alt='Profile banner'
height: '100%', className='w-100 rounded-4 shadow-sm'
background: 'linear-gradient(45deg, #6c757d, #495057)' style={{ height: '200px', objectFit: 'cover' }}
}} onError={() => handleImageError('banner')}
> onLoad={() => handleImageLoad('banner')}
<FontAwesomeIcon icon={faUser} size='3x' color='#7c7c7d' /> />
</div>
</div> </div>
)}
<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 */}
<div style={{ width: '120px', height: '120px' }} className='mb-3 mb-md-0'>
{post.picture && !imageError.picture
? (
<img
src={post.picture}
alt='Profile'
className='rounded-circle shadow w-100 h-100'
style={{ objectFit: 'cover' }}
onError={() => handleImageError('picture')}
onLoad={() => handleImageLoad('picture')}
/>
)
: (
<div
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center'
style={{
width: '100%',
height: '100%',
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
<FontAwesomeIcon icon={faUser} size='3x' color='#7c7c7d' />
</div>
)}
</div>
{/* 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}</h3> <h3 className='mb-2 fw-bold'>{post.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 className='fs-6 text-secondary'>
<NostrFormat content={post.about} />
</div> </div>
{/* About Section */}
{post.about && (
<div className='fs-6 text-secondary'>
<NostrFormat content={post.about} />
</div>
)}
{/* Website Link */}
{post.website && (
<div className='mb-3 d-flex align-items-center gap-2'>
<FontAwesomeIcon icon={faGlobe} className='text-muted' size='sm' />
<a
href={post.website.startsWith('http') ? post.website : `https://${post.website}`}
target='_blank'
rel='noopener noreferrer'
className='text-decoration-none text-muted small'
style={{ wordBreak: 'break-all' }}
>
{post.website}
</a>
</div>
)}
</div> </div>
{/* Action Buttons */}
<div className='d-flex gap-2 align-items-center flex-wrap justify-content-center'> <div className='d-flex gap-2 align-items-center flex-wrap justify-content-center'>
<Button <Button
variant='outline-danger' variant='outline-danger'
@@ -79,7 +79,7 @@ function PublicRead (props) {
<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
+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='/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 === '/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>
</> </>
) )
+10 -3
View File
@@ -14,7 +14,8 @@ function useAppState () {
defaultValue: { defaultValue: {
serverUrl: 'https://free-bch.fullstack.cash' // Default server serverUrl: 'https://free-bch.fullstack.cash' // Default server
}, },
nftData: {} nftData: {},
lastFeedTab: 'feed'
}) })
console.log('lsState: ', lsState) console.log('lsState: ', lsState)
@@ -26,6 +27,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')
// Startup state management // Startup state management
const [asyncInitStarted, setAsyncInitStarted] = useState(false) const [asyncInitStarted, setAsyncInitStarted] = useState(false)
@@ -37,6 +39,7 @@ 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 || {})
@@ -53,7 +56,7 @@ function useAppState () {
// console.log('lsState: ', lsState) // console.log('lsState: ', lsState)
const removeLocalStorageItem = removeItem const removeLocalStorageItem = removeItem
const updateLocalStorage = (lsObj) => { const updateLocalStorage = (lsObj) => {
// console.log(`updateLocalStorage() input: ${JSON.stringify(lsObj, null, 2)}`) console.log(`updateLocalStorage() input: ${JSON.stringify(lsObj, null, 2)}`)
// Progressively overwrite the LocalStorage state. // Progressively overwrite the LocalStorage state.
const newObj = Object.assign({}, lsState, lsObj) const newObj = Object.assign({}, lsState, lsObj)
@@ -149,7 +152,11 @@ function useAppState () {
setNostr, setNostr,
nftForSaleCacheData, nftForSaleCacheData,
setNftForSaleCacheData, setNftForSaleCacheData,
updateNFTCachedData updateNFTCachedData,
lastFeedTab,
setLastFeedTab,
isSingleView,
setIsSingleView
} }
} }
+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) {