Compare commits

...
6 Commits
Author SHA1 Message Date
Chris Troutner 990e41e8f6 Merge pull request #27 from Permissionless-Software-Foundation/dh-content-creators
feat(nostr): Created prototype for Social Media Content Creators
2025-07-06 14:36:26 -07:00
Daniel Gonzalez 9d35d80bcf feat(nostr): Created prototype for Social Media Content Creators 2025-07-05 22:05:57 -04:00
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
8 changed files with 366 additions and 65 deletions
+3 -1
View File
@@ -26,6 +26,7 @@ import ServerSelectView from './configuration/select-server-view'
import NostrPost from './nostr/nostr-post/index.js' import NostrPost from './nostr/nostr-post/index.js'
import NostrRead from './nostr/nostr-read/index.js' import NostrRead from './nostr/nostr-read/index.js'
import GlobalFeed from './nostr/global-feed/index.js' import GlobalFeed from './nostr/global-feed/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'
function AppBody (props) { function AppBody (props) {
@@ -50,7 +51,8 @@ function AppBody (props) {
<Route path='/nostr-post' element={<NostrPost appData={appData} />} /> <Route path='/nostr-post' element={<NostrPost appData={appData} />} />
<Route path='/nostr-read' element={<NostrRead appData={appData} />} /> <Route path='/nostr-read' element={<NostrRead appData={appData} />} />
<Route path='/global-feed' element={<GlobalFeed appData={appData} />} /> <Route path='/global-feed' element={<GlobalFeed appData={appData} />} />
<Route path='/user-data/:cid' element={<UserDataReview appData={appData} />} /> <Route path='/content-creators' element={<ContentCreators 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} />} />
</Routes> </Routes>
{/** Show in all paths except the servers view */} {/** Show in all paths except the servers view */}
@@ -102,7 +102,7 @@ function InfoButton (props) {
<Col xs={4}><b>User Data</b>:</Col> <Col xs={4}><b>User Data</b>:</Col>
<Col xs={8}> <Col xs={8}>
<a <a
href={`/user-data/${mutableDataCid}`} href={`/user-data/${props.token.tokenId}`}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='btn btn-link p-0' className='btn btn-link p-0'
@@ -0,0 +1,194 @@
/**
* Content creators card
*/
import React, { useState, useEffect } from 'react'
import { Card } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import { RelayPool } from 'nostr'
function ContentCard (props) {
const [profile, setProfile] = useState([])
const [loaded, setLoaded] = useState(false)
const { creator } = props
useEffect(() => {
const loadProfile = async () => {
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [creator.pubkey] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
console.log('profile', profile)
setProfile(profile)
} catch (error) {
console.log('Error parsing profile', error)
}
})
setLoaded(true)
}
if (!loaded && creator.npub) {
loadProfile()
}
}, [loaded, creator])
return (
<>
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-4'>
{/* Desktop Layout - Horizontal */}
<div className='d-flex justify-content-end mb-2'>
<small className='text-muted'>
{new Date(creator.createdAt).toLocaleString()}
</small>
</div>
<div className='d-none d-md-flex align-items-center gap-4'>
{/* Profile Picture */}
<div className='flex-shrink-0'>
<Jdenticon size='140' value={creator.npub} />
</div>
{/* Creator Info */}
<div className='flex-grow-1'>
<h5 className='mb-2 fw-bold'>{profile.name}</h5>
<p className='text-muted medium mb-3'>{profile.about}</p>
{/* Nostr Public Key */}
<div className='mb-2'>
<div className='d-flex align-items-center gap-2 mb-1'>
<small className='text-muted fw-semibold'>Nostr Public Key:</small>
</div>
<div className='d-flex align-items-center gap-2'>
<div className='text-break small text-secondary'>
{creator.npub}
</div>
<CopyOnClick
walletProp='npub'
appData={props.appData}
value={creator.npub}
/>
</div>
</div>
{/* BCH Address */}
<div className='mb-3'>
<div className='d-flex align-items-center gap-2 mb-1'>
<small className='text-muted fw-semibold'>BCH Address:</small>
</div>
<div className='d-flex align-items-center gap-2'>
<div className='text-break small text-secondary'>
{creator.bchAddr}
</div>
<CopyOnClick
walletProp='bchAddress'
appData={props.appData}
value={creator.bchAddr}
/>
</div>
</div>
</div>
{/* Action Buttons */}
<div className='flex-shrink-0 d-flex flex-column gap-2'>
<button
className='btn btn-outline-danger btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem', minWidth: '100px' }}
>
<i className='bi bi-person-plus me-1' />
Follow
</button>
<button
className='btn btn-primary btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem', minWidth: '100px' }}
>
<i className='bi bi-chat-dots me-1' />
Message
</button>
</div>
</div>
{/* Mobile Layout - Vertical */}
<div className='d-flex d-md-none flex-column align-items-center text-center'>
{/* Profile Picture */}
<div className='mb-3'>
<Jdenticon size='100' value={creator.npub} />
</div>
{/* Creator Info */}
<div className='w-100 mb-3'>
<h5 className='mb-2 fw-bold'>{profile.name}</h5>
<p className='text-muted small mb-3'>{profile.about}</p>
{/* Nostr Public Key */}
<div className='mb-3'>
<div className='mb-1'>
<small className='text-muted fw-semibold'>Nostr Public Key:</small>
</div>
<div className='d-flex align-items-center justify-content-center gap-2'>
<div className='text-break small text-secondary' style={{ maxWidth: '200px' }}>
{creator.npub}
</div>
<CopyOnClick
walletProp='npub'
appData={props.appData}
value={creator.npub}
/>
</div>
</div>
{/* BCH Address */}
<div className='mb-3'>
<div className='mb-1'>
<small className='text-muted fw-semibold'>BCH Address:</small>
</div>
<div className='d-flex align-items-center justify-content-center gap-2'>
<div className='text-break small text-secondary' style={{ maxWidth: '200px' }}>
{creator.bchAddr}
</div>
<CopyOnClick
walletProp='bchAddress'
appData={props.appData}
value={creator.bchAddr}
/>
</div>
</div>
</div>
{/* Action Buttons */}
<div className='d-flex gap-2'>
<button
className='btn btn-outline-danger btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem' }}
>
<i className='bi bi-person-plus me-1' />
Follow
</button>
<button
className='btn btn-primary btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem' }}
>
<i className='bi bi-chat-dots me-1' />
Message
</button>
</div>
</div>
</Card.Body>
</Card>
</>
)
}
export default ContentCard
@@ -0,0 +1,65 @@
import React, { useState, useEffect } from 'react'
import { Container, Spinner } from 'react-bootstrap'
import axios from 'axios'
// Local libraries
import config from '../../../../config'
import ContentCard from './content-card'
// Global variables and constants
const SERVER = config.dexServer
function ContentCreators (props) {
const [creators, setCreators] = useState([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
const loadCreators = async () => {
try {
const creators = await axios.get(`${SERVER}/sm/list/all/0`)
console.log(creators.data)
setCreators(creators.data)
setLoaded(true)
} catch (error) {
setLoaded(true)
}
}
if (!loaded) {
loadCreators()
}
}, [loaded])
return (
<Container className='mt-4 mb-5'>
<div className='mb-4'>
<h2 className='text-center mb-3'>Content Creators</h2>
<p className='text-center text-muted'>
Discover amazing content creators in the Bitcoin Cash ecosystem
</p>
</div>
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
<div className='mt-3'>Loading content creators...</div>
</div>
)}
{loaded && (
<div>
{creators.map((creator, i) => (
<ContentCard key={`creator-key${i}`} creator={creator} appData={props.appData} />
))}
</div>
)}
{loaded && creators.length === 0 && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<i className='bi bi-people-fill fs-1 mb-3 d-block opacity-50' />
<div>No content creators found</div>
</div>
)}
</Container>
)
}
export default ContentCreators
@@ -0,0 +1,15 @@
import React from 'react'
import { Container } from 'react-bootstrap'
import ContentCreatorsList from './content-creators-list.js'
function ContentCreators (props) {
return (
<>
<Container>
<ContentCreatorsList {...props} />
</Container>
</>
)
}
export default ContentCreators
@@ -47,11 +47,14 @@ function PublicPost (props) {
// Relay list // Relay list
const psf = 'wss://nostr-relay.psfoundation.info' const psf = 'wss://nostr-relay.psfoundation.info'
// BCH address of the user.
const bchAddr = bchWalletState.address
// Generate a post. // Generate a post.
const eventTemplate = { const eventTemplate = {
kind: 1, kind: 1,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [['t', 'slpdex-socialmedia']], tags: [['t', 'slpdex-socialmedia'], ['u', bchAddr]],
content: formData.content content: formData.content
} }
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`) console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
+77 -62
View File
@@ -5,25 +5,34 @@
// Global npm libraries // Global npm libraries
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { Container, Row, Col, Tabs, Tab, Spinner, Carousel } from 'react-bootstrap' import { Container, Row, Col, Spinner, Carousel } from 'react-bootstrap'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
function UserDataReview (props) { function UserDataReview (props) {
const appData = props.appData const appData = props.appData
const [media, setMedia] = useState([]) const [media, setMedia] = useState([])
const [markdown, setMarkdown] = useState('') const [markdown, setMarkdown] = useState('')
const [activeTab, setActiveTab] = useState('media')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [tokenData, setTokenData] = useState(null)
// Get the id parameter from the URL // Get the id parameter from the URL
const { cid } = useParams() const { tokenId } = useParams()
useEffect(() => { useEffect(() => {
const loadMedia = async () => { const loadData = async () => {
setLoading(true)
try { 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 { json } = await appData.wallet.cid2json({ cid })
const userDataString = json.userData const userDataString = json.userData
if (userDataString) { if (userDataString) {
const userData = JSON.parse(userDataString) const userData = JSON.parse(userDataString)
setMedia(userData.media) setMedia(userData.media)
@@ -34,74 +43,80 @@ function UserDataReview (props) {
} }
setLoading(false) setLoading(false)
} }
loadMedia() loadData()
}, [cid, appData.wallet]) }, [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 ( return (
<Container> <Container>
<Row> <Row>
<Col> <Col>
{/** Error message */}
{error && <p className='text-danger'>{error}</p>} {error && <p className='text-danger'>{error}</p>}
{loading
? ( {/** Loading spinner */}
{loading &&
(
<div className='text-center my-5'> <div className='text-center my-5'>
<Spinner animation='border' role='status' variant='primary'> <Spinner animation='border' role='status' variant='primary'>
<span className='visually-hidden'>Loading...</span> <span className='visually-hidden'>Loading...</span>
</Spinner> </Spinner>
</div> </div>
) )}
: ( {/** User data */}
<Tabs {!loading && !error && (
activeKey={activeTab} <div className='text-center'>
onSelect={(k) => setActiveTab(k)} {/** Name */}
className='mb-4' <h1>{tokenData.genesisData.name}</h1>
>
<Tab eventKey='media' title='Media'> {/** Media Content Carousel */}
{media && media.length > 0 {media && media.length > 0
? ( ? (
<div className='mt-3 mb-5'> <div className='my-5 '>
<Carousel> <Carousel>
{media.map((item, index) => ( {media.map((item, index) => (
<Carousel.Item key={index}> <Carousel.Item key={index}>
<img <img
className='d-block w-100' className='d-block w-100'
src={item.url} src={item.url}
alt={`Review media ${index + 1}`} alt={`Review media ${index + 1}`}
style={{ height: '400px', objectFit: 'contain' }} style={{ height: '400px', objectFit: 'contain' }}
/> />
<Carousel.Caption> <Carousel.Caption>
<p>Image {index + 1} of {media.length}</p> <p>Image {index + 1} of {media.length}</p>
</Carousel.Caption> </Carousel.Caption>
</Carousel.Item> </Carousel.Item>
))} ))}
</Carousel> </Carousel>
<style>{` <style>{'.carousel-control-next,.carousel-control-prev, .carousel-indicators {filter: invert(100%); } '}
.carousel-control-next, </style>
.carousel-control-prev, </div>
.carousel-indicators { )
filter: invert(100%); : (
} <p className='mt-3'>No media content available</p>
`} )}
</style>
</div> {/** Markdown Content */}
) {markdown
: ( ? (
<p className='mt-3'>No media content available</p> <div className='markdown-content my-5 text-center'>
)} <ReactMarkdown>{markdown}</ReactMarkdown>
</Tab> </div>
<Tab eventKey='markdown' title='Content'> )
{markdown : (
? ( <p className='mt-3'>No content available</p>
<div className='markdown-content mt-3'> )}
<ReactMarkdown>{markdown}</ReactMarkdown> </div>
</div> )}
)
: (
<p className='mt-3'>No content available</p>
)}
</Tab>
</Tabs>
)}
</Col> </Col>
</Row> </Row>
</Container> </Container>
+7
View File
@@ -77,6 +77,13 @@ function NavMenu (props) {
> >
Nostr Profile Nostr Profile
</NavLink> </NavLink>
<NavLink
className={currentPath === '/content-creators' ? 'nav-link-active' : 'nav-link-inactive'}
to='/content-creators'
onClick={handleClickEvent}
>
Content Creators
</NavLink>
<hr /> <hr />