Compare commits

...
6 Commits
Author SHA1 Message Date
Chris Troutner f4c47d4ce3 Merge pull request #53 from Permissionless-Software-Foundation/dh-profile-todos
feat(nostr): Completed profile feed TODOs
2025-08-15 11:58:43 -07:00
Chris Troutner 121f1001e1 Merge branch 'master' into dh-profile-todos 2025-08-15 10:36:20 -07:00
Chris Troutner e8f077dba2 Merge pull request #52 from Permissionless-Software-Foundation/dh-feeds-todos
feat(nostr): Completed TODOs on feeds page
2025-08-15 10:35:37 -07:00
Chris Troutner 3fbbdbc6a7 minor edits 2025-08-15 10:33:47 -07:00
Daniel Gonzalez 43013bd464 feat(nostr): Completed profile feed TODOs 2025-08-15 12:55:34 -04:00
Daniel Gonzalez 391ddb62de feat(nostr): Completed TODOs on feeds page 2025-08-14 11:39:31 -04:00
5 changed files with 208 additions and 140 deletions
@@ -1,6 +1,7 @@
/** /**
* Component for displaying nostr posts * Component for displaying nostr posts
*/ */
// Global npm libraries // Global npm libraries
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { Card, Spinner } from 'react-bootstrap' import { Card, Spinner } from 'react-bootstrap'
@@ -8,12 +9,15 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser, faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons' import { faUser, faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons'
import { faHeart } from '@fortawesome/free-regular-svg-icons' import { faHeart } from '@fortawesome/free-regular-svg-icons'
import * as nip19 from 'nostr-tools/nip19' import * as nip19 from 'nostr-tools/nip19'
import NostrFormat from '../nostr-format'
import { finalizeEvent } from 'nostr-tools/pure' import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay' import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import { RelayPool } from 'nostr' import { RelayPool } from 'nostr'
// Local libraries
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import NostrFormat from '../nostr-format'
function FeedCard (props) { function FeedCard (props) {
const { post, appData, profiles } = props const { post, appData, profiles } = props
const { nostrKeyPair } = appData.bchWalletState const { nostrKeyPair } = appData.bchWalletState
@@ -21,7 +25,6 @@ function FeedCard (props) {
const [profile, setProfile] = useState(profiles[post.pubkey]) const [profile, setProfile] = useState(profiles[post.pubkey])
const [npub, setNpub] = useState('') const [npub, setNpub] = useState('')
const [isClicked, setIsClicked] = useState(false)
const [isLiked, setIsLiked] = useState(false) const [isLiked, setIsLiked] = useState(false)
const [likesCount, setLikesCount] = useState(null) const [likesCount, setLikesCount] = useState(null)
const [likesFetched, setLikesFetched] = useState(false) const [likesFetched, setLikesFetched] = useState(false)
@@ -104,13 +107,6 @@ function FeedCard (props) {
} }
}, [profiles, post]) }, [profiles, post])
// copy to clipboard
const copyToClipboard = useCallback((value) => {
setIsClicked(true)
appData.appUtil.copyToClipboard(value)
setTimeout(() => setIsClicked(false), 200)
}, [appData])
// get short npub // get short npub
const getShortNpub = useCallback((npub) => { const getShortNpub = useCallback((npub) => {
return npub.slice(0, 8) + '...' + npub.slice(-5) return npub.slice(0, 8) + '...' + npub.slice(-5)
@@ -168,6 +164,11 @@ function FeedCard (props) {
} }
} }
const goToProfile = () => {
const profileUrl = `${window.location.origin}/profile/${npub}#single-view`
window.open(profileUrl, '_blank')
}
return ( return (
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'> <Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'> <Card.Body className='p-3'>
@@ -186,20 +187,21 @@ function FeedCard (props) {
src={profile.picture} src={profile.picture}
alt='Profile' alt='Profile'
className='rounded-circle w-100 h-100' className='rounded-circle w-100 h-100'
style={{ objectFit: 'cover' }} style={{ objectFit: 'cover', cursor: 'pointer' }}
onError={handleProfilePictureError} onError={handleProfilePictureError}
onLoad={handleProfilePictureLoad} onLoad={handleProfilePictureLoad}
onClick={goToProfile}
/> />
) )
: ( : (
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' /> <FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' style={{ cursor: 'pointer' }} onClick={goToProfile} />
)} )}
</div> </div>
<div className='flex-grow-1'> <div className='flex-grow-1'>
<div className='fw-bold mb-1'> <div className='fw-bold mb-1'>
{profile && profile.name && <span>{profile.name}</span>} {profile && profile.name && <span style={{ cursor: 'pointer' }} onClick={goToProfile}>{profile.name}</span>}
{!profile?.name && ( {!profile?.name && (
<span> <span style={{ cursor: 'pointer' }} onClick={goToProfile}>
{post.pubkey.slice(0, 8) + '...'} {post.pubkey.slice(0, 8) + '...'}
{!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />} {!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />}
</span> </span>
@@ -211,14 +213,20 @@ function FeedCard (props) {
title='Copy to clipboard' title='Copy to clipboard'
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
transform: isClicked ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease', transition: 'transform 0.1s ease',
display: 'inline-block' display: 'inline-block',
marginRight: '5px'
}} }}
onClick={() => copyToClipboard(npub)} onClick={goToProfile}
> >
{getShortNpub(npub)} {getShortNpub(npub)}
</span> </span>
<CopyOnClick
walletProp='npub'
appData={props.appData}
value={npub}
/>
</small> </small>
)} )}
</div> </div>
+92 -73
View File
@@ -1,16 +1,5 @@
/* /*
Component for reading the nostr feeds. Component for reading the nostr feeds.
TODO:
- fetchProfile() should retrieve a profile from multiple relays. If the first relay returns a profile,
then that profile can be used and promise resolved. If the first relay returns no profile, the next
one should be tried until all relays are exhausted or one returns a profile.
- useEffect() retrieves the feeds. This should cycle through each relay and posts from each one.
Once each relays has been tried, the posts should remove duplicate entries. Finally posts should
be sorted by date.
- Clicking on a profile picture, name, or npub should open the profile for that user in a new tab.
*/ */
// Global npm libraries // Global npm libraries
@@ -27,91 +16,121 @@ function Feeds (props) {
const { appData } = props const { appData } = props
const [activeTab, setActiveTab] = useState(appData.lastFeedTab) const [activeTab, setActiveTab] = useState(appData.lastFeedTab)
const { bchWalletState } = appData
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [profiles, setProfiles] = useState({}) const [profiles, setProfiles] = useState({})
// function to fetch profile and set it to profiles state // Load profile from nostr relays
const fetchProfile = useCallback(async (pubkey) => { // It uses multiple relays. It will exit after the first successful retrieval
// no fetch profile again if it exist // from any relay. If one relay fails, it will move on to the next one.
let hasProfileRequest = false const fetchProfile = useCallback(async (pubKey) => {
setProfiles(currentProfiles => { // Looking for the profile in each relay sequentially
if (currentProfiles[pubkey]) { for (let i = 0; i < config.nostrRelays.length; i++) {
hasProfileRequest = true const profile = await new Promise((resolve) => {
return currentProfiles const relay = config.nostrRelays[i]
} else { // const pool = RelayPool(config.nostrRelays)
const newProfiles = { ...currentProfiles } const pool = RelayPool([relay])
newProfiles[pubkey] = { loaded: false } pool.on('open', relay => {
return newProfiles relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubKey] })
} })
})
if (hasProfileRequest) return
// const pool = RelayPool(config.nostrRelays) pool.on('eose', relay => {
const pool = RelayPool([config.nostrRelay]) console.log('Closing Relay')
pool.on('open', relay => { relay.close()
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubkey] }) resolve(false)
}) })
pool.on('eose', relay => { pool.on('event', (relay, subId, ev) => {
relay.close() try {
try { const profile = JSON.parse(ev.content)
// Mark unknown profiles. From this way we can know which profile was fetched and not found. // console.log('profile', profile)
setProfiles(currentProfiles => { console.log(`Profile found for ${pubKey} at ${relay.url}`)
if (!currentProfiles[pubkey]) { resolve(profile)
const newProfiles = { ...currentProfiles } } catch (error) {
newProfiles[pubkey] = { loaded: true } resolve(false)
return newProfiles
} }
return currentProfiles relay.close()
}) })
} catch (error) { })
console.warn(error) // Stop looking for profile if found
// skip error if (profile) {
return profile
} }
}) }
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 // Get array of feeds from relays
useEffect(() => { const fetchFeeds = useCallback(async () => {
const start = () => { let feeds = await new Promise((resolve, reject) => {
// const pool = RelayPool(config.nostrRelays) let list = []
const pool = RelayPool([config.nostrRelay]) let closedRelays = 0
const pool = RelayPool(config.nostrRelays)
// const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => { pool.on('open', relay => {
relay.subscribe('REQ', { limit: 10, kinds: [1], '#t': ['slpdex-socialmedia'] }) relay.subscribe('REQ', { limit: 10, kinds: [1], '#t': ['slpdex-socialmedia'] })
}) })
pool.on('eose', relay => { pool.on('eose', relay => {
setLoaded(true)
relay.close() relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === config.nostrRelays.length) {
resolve(list)
}
}) })
pool.on('event', async (relay, subId, ev) => { pool.on('event', (relay, subId, ev) => {
setPosts(currentPosts => [...currentPosts, ev]) // console.log('post retrieved from ', relay.url, ev.sig)
// fetch post profile and set it to profiles state list = [...list, ev]
fetchProfile(ev.pubkey)
}) })
})
// Remove duplicated feeds
feeds = feeds.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
// Sort from newest to oldest
feeds.sort((a, b) => b.created_at - a.created_at)
// console.log('feeds', feeds)
setPosts(feeds)
return feeds
}, [])
// Load data on component mount.
useEffect(() => {
const loadData = async () => {
// Get feeds
const feeds = await fetchFeeds()
setLoaded(true)
const loadedProfiles = [] // fetched profiles ( this will be used for prevent load the same profile multiple times.)
// Map feeds and get feed owner profile.
for (let i = 0; i < feeds.length; i++) {
const pubKey = feeds[i].pubkey
const exist = loadedProfiles.find((val) => { return val === pubKey })
if (exist) { continue }
// Fech profile.
const profile = await fetchProfile(pubKey)
loadedProfiles.push(pubKey) // mark as loaded
// Update profile state
setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles }
newProfiles[pubKey] = profile
return newProfiles
})
}
} }
if (!loaded) { if (!loaded) {
start() loadData()
} }
}, [bchWalletState, loaded, fetchProfile]) }, [loaded, fetchFeeds, fetchProfile])
const onChangeTab = (tab) => { const onChangeTab = (tab) => {
setActiveTab(tab) setActiveTab(tab)
@@ -17,7 +17,7 @@ function Profile (props) {
return ( return (
<> <>
<Container> <Container>
<ProfileRead {...props} setProfile={setProfile} npub={npub} /> <ProfileRead {...props} onProfileRead={setProfile} npub={npub} />
<PublicRead {...props} profile={profile} npub={npub} /> <PublicRead {...props} profile={profile} npub={npub} />
<SlpTokensDisplay {...props} npub={npub} /> <SlpTokensDisplay {...props} npub={npub} />
<NFTForSale {...props} npub={npub} /> <NFTForSale {...props} npub={npub} />
@@ -1,13 +1,10 @@
/** /**
* Component to read nostr information kind 0 (normal posts) * Component to read nostr information kind 0 (normal posts)
* *
* TODO:
* - Currently feeds are retrieved from only one relay. It should retrieve Kind
* 0 posts from all relays. It should then remove any duplicate entries.
*/ */
// Global npm libraries // Global npm libraries
import React, { useEffect, useState } from 'react' import React, { useEffect, useState, useCallback } 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'
@@ -21,47 +18,66 @@ import { RelayPool } from 'nostr'
function ProfileRead (props) { function ProfileRead (props) {
const { npub } = props const { npub } = props
const { setProfile } = props const { onProfileRead } = props
const [post, setPost] = useState({}) const [profile, setProfile] = useState({})
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [imageError, setImageError] = useState({ picture: false, banner: false }) const [imageError, setImageError] = useState({ picture: false, banner: false })
// Load profile from nostr relays
// It uses multiple relays. It will exit after the first successful retrieval
// from any relay. If one relay fails, it will move on to the next one.
const fetchProfile = useCallback(async (pubKey) => {
console.log('fetching profile for pubkey ', pubKey)
// Looking for the profile in each relay sequentially
for (let i = 0; i < config.nostrRelays.length; i++) {
const profile = await new Promise((resolve) => {
const relay = config.nostrRelays[i]
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([relay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubKey] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
resolve(false)
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
// console.log('profile', profile)
console.log(`Profile found for ${pubKey} at ${relay.url}`)
resolve(profile)
} catch (error) {
resolve(false)
}
relay.close()
})
})
// Stop looking for profile if found
if (profile) {
return profile
}
}
}, [])
useEffect(() => { useEffect(() => {
const start = () => { const start = async () => {
const pubHexData = nip19.decode(npub) const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data const pubHex = pubHexData.data
// const pool = RelayPool(config.nostrRelays) const profile = await fetchProfile(pubHex)
const pool = new RelayPool([config.nostrRelay]) onProfileRead(profile)
pool.on('open', relay => { setProfile(profile)
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubHex] })
setLoaded(true)
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
try {
const profile = JSON.parse(ev.content)
console.log('Profile:', profile)
setPost(profile)
setProfile(profile)
} catch (error) {
console.error('Error parsing profile:', error)
}
})
setLoaded(true) setLoaded(true)
} }
if (!loaded && npub) { if (!loaded && npub) {
start() start()
} }
}, [loaded, setProfile, npub]) }, [loaded, onProfileRead, npub, fetchProfile])
const handleImageError = (type) => { const handleImageError = (type) => {
setImageError(prev => ({ ...prev, [type]: true })) setImageError(prev => ({ ...prev, [type]: true }))
@@ -74,10 +90,10 @@ function ProfileRead (props) {
return ( return (
<Container> <Container>
{/* Banner Section */} {/* Banner Section */}
{post.banner && !imageError.banner && ( {profile.banner && !imageError.banner && (
<div className='position-relative'> <div className='position-relative'>
<img <img
src={post.banner} src={profile.banner}
alt='Profile banner' alt='Profile banner'
className='w-100 rounded-4 shadow-sm' className='w-100 rounded-4 shadow-sm'
style={{ height: '200px', objectFit: 'cover' }} style={{ height: '200px', objectFit: 'cover' }}
@@ -90,10 +106,10 @@ function ProfileRead (props) {
<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'> <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 */} {/* Profile Picture */}
<div style={{ width: '120px', height: '120px' }} className='mb-3 mb-md-0'> <div style={{ width: '120px', height: '120px' }} className='mb-3 mb-md-0'>
{post.picture && !imageError.picture {profile.picture && !imageError.picture
? ( ? (
<img <img
src={post.picture} src={profile.picture}
alt='Profile' alt='Profile'
className='rounded-circle shadow w-100 h-100' className='rounded-circle shadow w-100 h-100'
style={{ objectFit: 'cover' }} style={{ objectFit: 'cover' }}
@@ -117,7 +133,7 @@ function ProfileRead (props) {
{/* Profile Information */} {/* 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 || 'username'}</h3> <h3 className='mb-2 fw-bold'>{profile.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'>
@@ -132,24 +148,24 @@ function ProfileRead (props) {
</div> </div>
{/* About Section */} {/* About Section */}
{post.about && ( {profile.about && (
<div className='fs-6 text-secondary'> <div className='fs-6 text-secondary'>
<NostrFormat content={post.about} /> <NostrFormat content={profile.about} />
</div> </div>
)} )}
{/* Website Link */} {/* Website Link */}
{post.website && ( {profile.website && (
<div className='mb-3 d-flex align-items-center gap-2'> <div className='mb-3 d-flex align-items-center gap-2'>
<FontAwesomeIcon icon={faGlobe} className='text-muted' size='sm' /> <FontAwesomeIcon icon={faGlobe} className='text-muted' size='sm' />
<a <a
href={post.website.startsWith('http') ? post.website : `https://${post.website}`} href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-decoration-none text-muted small' className='text-decoration-none text-muted small'
style={{ wordBreak: 'break-all' }} style={{ wordBreak: 'break-all' }}
> >
{post.website} {profile.website}
</a> </a>
</div> </div>
)} )}
@@ -2,7 +2,7 @@
* Component for read nostr information kind 1 * Component for read nostr information kind 1
*/ */
// Global npm libraries // Global npm libraries
import React, { useEffect, useState } from 'react' import React, { useEffect, useState, useCallback } from 'react'
import { Container, Card, Spinner } from 'react-bootstrap' import { Container, Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons' import { faUser } from '@fortawesome/free-solid-svg-icons'
@@ -20,35 +20,60 @@ function PublicRead (props) {
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [profilePictureError, setProfilePictureError] = useState(false) const [profilePictureError, setProfilePictureError] = useState(false)
useEffect(() => { const getUserFeeds = useCallback(async () => {
// Get Last post from a author let feeds = await new Promise((resolve) => {
const start = () => { let list = []
let closedRelays = 0
const pubHexData = nip19.decode(npub) const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data const pubHex = pubHexData.data
console.log('pubhex', pubHex) console.log('pubhex', pubHex)
const pool = RelayPool(config.nostrRelays) const pool = RelayPool(config.nostrRelays)
pool.on('open', relay => { pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] }) relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] })
setLoaded(true)
}) })
pool.on('eose', relay => { pool.on('eose', relay => {
console.log('Closing Relay') console.log('Closing Relay')
setLoaded(true)
relay.close() relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === config.nostrRelays.length) {
resolve(list)
}
}) })
pool.on('event', (relay, subId, ev) => { pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev) console.log('Received event:', ev)
setPosts(currentPosts => [...currentPosts, ev]) list = [...list, ev]
}) })
})
console.log('feeds', feeds)
// Remove duplicated feeds
feeds = feeds.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
// Sort from newest to oldest
feeds.sort((a, b) => b.created_at - a.created_at)
setPosts(feeds)
}, [npub])
useEffect(() => {
// Get Last post from a author
const start = async () => {
setLoaded(true)
await getUserFeeds()
setLoaded(false)
} }
if (!loaded && npub) { if (!loaded && npub) {
start() start()
} }
}, [bchWalletState, loaded, npub]) }, [bchWalletState, loaded, npub, getUserFeeds])
const handleProfilePictureError = () => { const handleProfilePictureError = () => {
setProfilePictureError(true) setProfilePictureError(true)