Compare commits

...
15 Commits
Author SHA1 Message Date
Daniel Gonzalez ad79d61dc1 feat(nostr): Added tabs to Feeds page 2025-07-22 12:29:21 -04:00
Chris Troutner ec4a4f8c41 Merge pull request #36 from Permissionless-Software-Foundation/dh-profile-fill
feat(nostr): Fill in Nostr Profile form with existing data
2025-07-20 06:23:18 -07:00
Daniel Gonzalez c14f60a36e feat(nostr): Fill in Nostr Profile form with existing data 2025-07-19 16:25:14 -04:00
Chris Troutner 062ad933d9 Merge pull request #35 from Permissionless-Software-Foundation/dh-follow-filter
feat(nostr): Filter Creators by Follower Count
2025-07-17 10:45:52 -07:00
Daniel Gonzalez a81dbb70e1 feat(nostr): Filter Creators by Follower Count 2025-07-17 11:41:57 -04:00
Chris Troutner 638e6f9e6d Merge pull request #34 from Permissionless-Software-Foundation/ct-unstable
fix(bch-dex-lib): Fixing bug preventing NFT sales
2025-07-15 17:37:19 -07:00
Chris Troutner 09cb79abab fix(bch-dex-lib): Fixing bug preventing NFT sales 2025-07-15 17:35:54 -07:00
Chris Troutner b15f55fc69 Merge pull request #33 from Permissionless-Software-Foundation/dh-profile-changes
feat(nostr): Refactored profile page
2025-07-15 09:30:05 -07:00
Daniel Gonzalez 98f308a467 Merge branch 'master' into dh-profile-changes 2025-07-15 11:38:54 -04:00
Daniel Gonzalez 701a84b2d5 feat(nostr): Refactored profile page 2025-07-15 11:36:01 -04:00
Chris Troutner 3b8d8e6238 Merge pull request #32 from Permissionless-Software-Foundation/ct-unstable
fix(config): Using main server
2025-07-15 06:14:34 -07:00
Chris Troutner ba46ce5927 fix(config): Using main server 2025-07-15 06:14:04 -07:00
Chris Troutner 98b57ecc75 Merge pull request #31 from Permissionless-Software-Foundation/ct-unstable
Adding filters to Content Creators page
2025-07-14 19:31:33 -07:00
Chris Troutner 78ac934e70 fix(filters): Adding filters drop-down to content creators 2025-07-14 19:13:34 -07:00
Chris Troutner 1d75be5b61 fix(followers): Listing number of followers for content creators 2025-07-14 19:08:11 -07:00
21 changed files with 849 additions and 411 deletions
+15 -3
View File
@@ -15,13 +15,13 @@
"@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "^1.8.0",
"axios": "0.27.2",
"bch-dex-lib": "2.0.2",
"bch-dex-lib": "2.0.3",
"bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1",
"bootstrap": "5.2.0",
"crypto-browserify": "3.12.1",
"marked": "^15.0.12",
"marked": "15.0.12",
"nostr-tools": "2.10.4",
"p2wdb": "2.4.1",
"process": "0.11.10",
@@ -5420,6 +5420,8 @@
},
"node_modules/@chris.troutner/retry-queue": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@chris.troutner/retry-queue/-/retry-queue-1.0.10.tgz",
"integrity": "sha512-iF+xQl3jyTATAKDVkwY85j4Npihh/MqG99bjrS0cNJDF0XQeUSMVGsdtXR6TFj8YcEgfPvgBdYGOtEFo6tTQpA==",
"license": "MIT",
"dependencies": {
"p-queue": "7.3.0",
@@ -8211,6 +8213,8 @@
},
"node_modules/@types/retry": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.1.tgz",
"integrity": "sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g==",
"license": "MIT"
},
"node_modules/@types/semver": {
@@ -10039,7 +10043,9 @@
"license": "MIT"
},
"node_modules/bch-dex-lib": {
"version": "2.0.2",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/bch-dex-lib/-/bch-dex-lib-2.0.3.tgz",
"integrity": "sha512-aPZIlDcRziZBRKRhEuAv6G/skFjZ28+sE30fkoUY8isUIAe3C0YS7yQMoOmGSA6N46/XuhRbfQi0SRQJfrASpA==",
"license": "MIT",
"dependencies": {
"@chris.troutner/retry-queue": "1.0.10",
@@ -22769,6 +22775,8 @@
},
"node_modules/p-queue": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-7.3.0.tgz",
"integrity": "sha512-5fP+yVQ0qp0rEfZoDTlP2c3RYBgxvRsw30qO+VtPPc95lyvSG+x6USSh1TuLB4n96IO6I8/oXQGsTgtna4q2nQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.7",
@@ -22794,6 +22802,8 @@
},
"node_modules/p-retry": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-5.1.1.tgz",
"integrity": "sha512-i69WkEU5ZAL8mrmdmVviWwU+DN+IUF8f4sSJThoJ3z5A7Nn5iuO5ROX3Boye0u+uYQLOSfgFl7SuFZCjlAVbQA==",
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.1",
@@ -22808,6 +22818,8 @@
},
"node_modules/p-timeout": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-5.1.0.tgz",
"integrity": "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==",
"license": "MIT",
"engines": {
"node": ">=12"
+1 -1
View File
@@ -9,7 +9,7 @@
"@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "^1.8.0",
"axios": "0.27.2",
"bch-dex-lib": "2.0.2",
"bch-dex-lib": "2.0.3",
"bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1",
+4 -4
View File
@@ -24,8 +24,8 @@ import SweepWif from './sweep/index.js'
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 Profile from './nostr/profile/index.js'
import Feeds from './nostr/feeds/index.js'
import ContentCreators from './nostr/content-creators/index.js'
import UserDataReview from './user-data-review'
import Offers from './offers'
@@ -49,8 +49,8 @@ function AppBody (props) {
<Route path='/sign' element={<SignMessage appData={appData} />} />
<Route path='/configuration' element={<ServerSelectView appData={appData} />} />
<Route path='/nostr-post' element={<NostrPost appData={appData} />} />
<Route path='/nostr-read/:npub' element={<NostrRead appData={appData} />} />
<Route path='/global-feed' element={<GlobalFeed appData={appData} />} />
<Route path='/profile/:npub' element={<Profile appData={appData} />} />
<Route path='/feeds' element={<Feeds 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} />} />
@@ -7,46 +7,17 @@ import { Card } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import FollowBtn from './follow-btn.js'
import { RelayPool } from 'nostr'
function ContentCard (props) {
const [profile, setProfile] = useState([])
const [loaded, setLoaded] = useState(false)
const { creator, followList, refreshFollowList } = props
const [profile, setProfile] = useState(null)
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])
setProfile(creator?.profile || {})
}, [creator.profile])
const goToProfile = () => {
const profileUrl = `${window.location.origin}/nostr-read/${creator.npub}`
const profileUrl = `${window.location.origin}/profile/${creator.npub}`
window.open(profileUrl, '_blank')
}
@@ -69,8 +40,8 @@ function ContentCard (props) {
{/* Creator Info */}
<div className='flex-grow-1'>
<h5 className='mb-2 fw-bold cursor-pointer' onClick={goToProfile}>{profile.name}</h5>
<p className='text-muted medium mb-3'>{profile.about}</p>
<h5 className='mb-2 fw-bold cursor-pointer' onClick={goToProfile}>{profile?.name}</h5>
<p className='text-muted medium mb-3'>{profile?.about}</p>
{/* Nostr Public Key */}
<div className='mb-2'>
@@ -117,6 +88,7 @@ function ContentCard (props) {
refreshFollowList={refreshFollowList}
/>
<button
className='btn btn-primary btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem', minWidth: '100px' }}
@@ -124,6 +96,17 @@ function ContentCard (props) {
<i className='bi bi-chat-dots me-1' />
Message
</button>
<div className='d-flex align-items-center justify-content-center gap-2 mt-2'>
<span
className='badge rounded-pill bg-white border text-dark px-3 py-2 shadow-sm d-flex align-items-center gap-2'
style={{ fontSize: '1rem', fontWeight: 600, cursor: 'auto' }}
>
<i className='bi bi-people-fill text-primary' style={{ fontSize: '1.2rem' }} />
<span style={{ fontWeight: 700 }}>{creator.followerCnt}</span>
<span className='text-muted' style={{ fontWeight: 400, fontSize: '0.9rem' }}>Followers</span>
</span>
</div>
</div>
</div>
@@ -136,8 +119,8 @@ function ContentCard (props) {
{/* Creator Info */}
<div className='w-100 mb-3'>
<h5 className='mb-2 fw-bold' onClick={goToProfile}>{profile.name}</h5>
<p className='text-muted small mb-3'>{profile.about}</p>
<h5 className='mb-2 fw-bold' onClick={goToProfile}>{profile?.name}</h5>
<p className='text-muted small mb-3'>{profile?.about}</p>
{/* Nostr Public Key */}
<div className='mb-3'>
@@ -191,6 +174,13 @@ function ContentCard (props) {
Message
</button>
</div>
<div className='d-flex align-items-center justify-content-center gap-2 mt-2'>
<span className='badge rounded-pill bg-white border text-dark px-3 py-2 shadow-sm d-flex align-items-center gap-2' style={{ fontSize: '1rem', fontWeight: 600 }}>
<i className='bi bi-people-fill text-primary' style={{ fontSize: '1.2rem' }} />
<span style={{ fontWeight: 700 }}>{creator.followerCnt}</span>
<span className='text-muted' style={{ fontWeight: 400, fontSize: '0.9rem' }}>Followers</span>
</span>
</div>
</div>
</Card.Body>
</Card>
@@ -1,5 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Spinner } from 'react-bootstrap'
import { Container, Spinner, Dropdown } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faFilter } from '@fortawesome/free-solid-svg-icons'
import axios from 'axios'
// Local libraries
@@ -12,6 +14,7 @@ const SERVER = config.dexServer
function ContentCreators (props) {
const [creators, setCreators] = useState([])
const [loaded, setLoaded] = useState(false)
const [selectedFilter, setSelectedFilter] = useState('Most Followers')
const { appData } = props
const [followList, setFollowList] = useState([])
@@ -47,13 +50,57 @@ function ContentCreators (props) {
setFollowList(list)
}, [appData])
const loadProfile = useCallback(async (pubKey) => {
return new Promise((resolve) => {
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()
resolve(false)
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
// console.log('profile', profile)
resolve(profile)
} catch (error) {
resolve(false)
}
})
})
}, [])
useEffect(() => {
const loadCreators = async () => {
try {
const creators = await axios.get(`${SERVER}/sm/list/all/0`)
console.log(creators.data)
setCreators(creators.data)
const creatorsRes = await axios.get(`${SERVER}/sm/list/all/0`)
const creators = creatorsRes.data
// console.log('creators', creators)
setCreators(creators)
setLoaded(true)
for (let i = 0; i < creators.length; i++) {
try {
const creator = creators[i]
const profile = await loadProfile(creator.pubkey)
// the folowing lines should re-render the ContentCard
setCreators(prevCreators => {
const updatedCreators = [...prevCreators]
updatedCreators[i] = { ...updatedCreators[i], profile }
return updatedCreators
})
} catch (error) {
console.warn(error)
}
}
} catch (error) {
setLoaded(true)
}
@@ -63,17 +110,52 @@ function ContentCreators (props) {
loadCreators()
getFollowList()
}
}, [loaded, followList, getFollowList])
}, [loaded, followList, getFollowList, loadProfile])
const filteredCreators = useCallback(() => {
if (!creators || creators.length === 0) {
return []
}
let filtered = [...creators]
if (selectedFilter === 'Most Followers') {
filtered = filtered.sort((a, b) => (b.followerCnt || 0) - (a.followerCnt || 0))
}
return filtered
}, [creators, selectedFilter])
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
Discover amazing creators tokenizing their content.
</p>
</div>
<div className='mb-4 d-flex justify-content-end'>
<Dropdown>
<Dropdown.Toggle variant='outline-secondary' className='d-flex align-items-center gap-2'>
<FontAwesomeIcon icon={faFilter} />
<span>{selectedFilter}</span>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item onClick={() => setSelectedFilter('Most Followers')}>
Most Followers
</Dropdown.Item>
<Dropdown.Item onClick={() => setSelectedFilter('Most Tokens')}>
Most Tokens
</Dropdown.Item>
<Dropdown.Item onClick={() => setSelectedFilter('Most Likes')}>
Most Likes
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
@@ -83,7 +165,7 @@ function ContentCreators (props) {
{loaded && (
<div>
{creators.map((creator, i) => (
{filteredCreators().map((creator, i) => (
<ContentCard
key={`creator-key${i}`}
creator={creator}
@@ -95,7 +177,7 @@ function ContentCreators (props) {
</div>
)}
{loaded && creators.length === 0 && (
{loaded && filteredCreators().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>
@@ -1,5 +1,5 @@
/**
* Component for reading global nostr posts
* Component for displaying nostr posts
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
@@ -61,7 +61,7 @@ function FeedCard (props) {
{!profile?.name && (
<span>
{post.pubkey.slice(0, 8) + '...'}
<Spinner animation='border' size='sm' className='ms-2' />
{!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />}
</span>
)}
</div>
@@ -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,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
@@ -3,17 +3,19 @@ Component for posting nostr information on kind 0.
*/
// Global npm libraries
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { Container, Form, Button, Spinner } from 'react-bootstrap'
import Accordion from 'react-bootstrap/Accordion'
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 ProfilePost (props) {
const { bchWalletState } = props.appData
const [accordionKey, setAccordionKey] = useState(null)
const [onFetch, setOnFetch] = useState(false)
const { bchWalletState } = props.appData
const [formLoaded, setFormLoaded] = useState(false)
const [formData, setFormData] = useState({
name: '',
about: '',
@@ -27,6 +29,43 @@ function ProfilePost (props) {
const [errorMsg, setErrorMsg] = useState('')
const [successMsg, setSuccessMsg] = useState('')
useEffect(() => {
// Get Last post from
const getLastPost = () => {
const { nostrKeyPair } = bchWalletState
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [0], authors: [nostrKeyPair.pubHex] })
setFormLoaded(true)
})
pool.on('eose', relay => {
console.log('Closing Relay')
setFormLoaded(true)
relay.close()
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
// setPosts(currentPosts => [...currentPosts, ev])
try {
const data = JSON.parse(ev.content)
console.log('data', data)
setFormData(data)
} catch (error) {
}
})
}
if (!formLoaded) {
getLastPost()
}
}, [bchWalletState, formLoaded])
const handleInputChange = (e) => {
const { name, value } = e.target
setFormData({
@@ -78,7 +117,6 @@ function ProfilePost (props) {
// Close the connection to the relay.
relay.close()
resetForm()
setSuccessMsg('Post successfully published!')
setOnFetch(false)
} catch (error) {
@@ -92,18 +130,6 @@ function ProfilePost (props) {
setAccordionKey(key)
}
const resetForm = () => {
setFormData({
name: '',
about: '',
picture: '',
display_name: '',
website: '',
banner: '',
bot: false
})
}
return (
<>
<Container className='mt-4'>
@@ -121,103 +147,111 @@ function ProfilePost (props) {
{successMsg}
</div>
)}
<Form onSubmit={handleSubmit}>
<Form.Group className='mb-3'>
<Form.Label>Name</Form.Label>
<Form.Control
type='text'
placeholder='Enter your name'
name='name'
value={formData.name}
onChange={handleInputChange}
required
/>
</Form.Group>
{formLoaded && (
<Form onSubmit={handleSubmit}>
<Form.Group className='mb-3'>
<Form.Label>Name</Form.Label>
<Form.Control
type='text'
placeholder='Enter your name'
name='name'
value={formData.name}
onChange={handleInputChange}
required
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>About</Form.Label>
<Form.Control
as='textarea'
rows={3}
placeholder='Tell us about yourself'
name='about'
value={formData.about}
onChange={handleInputChange}
required
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>About</Form.Label>
<Form.Control
as='textarea'
rows={3}
placeholder='Tell us about yourself'
name='about'
value={formData.about}
onChange={handleInputChange}
required
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Profile Picture URL</Form.Label>
<Form.Control
type='url'
placeholder='Enter URL to your profile picture'
name='picture'
value={formData.picture}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Profile Picture URL</Form.Label>
<Form.Control
type='url'
placeholder='Enter URL to your profile picture'
name='picture'
value={formData.picture}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Display Name</Form.Label>
<Form.Control
type='text'
placeholder='Enter your display name'
name='display_name'
value={formData.display_name}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Display Name</Form.Label>
<Form.Control
type='text'
placeholder='Enter your display name'
name='display_name'
value={formData.display_name}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Website</Form.Label>
<Form.Control
type='url'
placeholder='Enter your website URL'
name='website'
value={formData.website}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Website</Form.Label>
<Form.Control
type='url'
placeholder='Enter your website URL'
name='website'
value={formData.website}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Banner URL</Form.Label>
<Form.Control
type='url'
placeholder='Enter URL to your banner picture (1024x768 pixels)'
name='banner'
value={formData.banner}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Banner URL</Form.Label>
<Form.Control
type='url'
placeholder='Enter URL to your banner picture (1024x768 pixels)'
name='banner'
value={formData.banner}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Check
type='checkbox'
label='This account is for a bot'
name='bot'
checked={formData.bot}
onChange={(e) => {
setFormData({
...formData,
bot: e.target.checked
})
setSuccessMsg('')
setErrorMsg('')
}}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Check
type='checkbox'
label='This account is for a bot'
name='bot'
checked={formData.bot}
onChange={(e) => {
setFormData({
...formData,
bot: e.target.checked
})
setSuccessMsg('')
setErrorMsg('')
}}
/>
</Form.Group>
<div className='d-flex justify-content-center'>
{!onFetch && (
<Button variant='primary' type='submit' disabled={onFetch}>
Post
</Button>
)}
{onFetch && <Spinner animation='border' variant='primary' />}
<div className='d-flex justify-content-center'>
{!onFetch && (
<Button variant='primary' type='submit' disabled={onFetch}>
Post
</Button>
)}
{onFetch && <Spinner animation='border' variant='primary' />}
</div>
</Form>
)}
{!formLoaded && (
<div className='text-center'>
<Spinner animation='border' variant='primary' />
</div>
)}
</Form>
</Accordion.Body>
</Accordion.Item>
</Accordion>
@@ -1,101 +0,0 @@
/**
* Component for read nostr information kind 1
*/
// 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 NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
function PublicRead (props) {
const { npub } = props
const { bchWalletState } = props.appData
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
// Get Last post from a author
const start = () => {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
console.log('pubhex', pubHex)
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] })
setLoaded(true)
})
pool.on('eose', relay => {
console.log('Closing Relay')
setLoaded(true)
relay.close()
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
setPosts(currentPosts => [...currentPosts, ev])
})
}
if (!loaded && npub) {
start()
}
}, [bchWalletState, loaded, npub])
return (
<Container className='mt-4'>
<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'>{props.profile?.name}</div>
<small className='text-muted'>
{`${bchWalletState.nostrKeyPair.npub.slice(0, 8)}...${bchWalletState.nostrKeyPair.npub.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' }}>
<NostrFormat content={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 PublicRead
@@ -8,8 +8,9 @@ import { Container } from 'react-bootstrap'
import ProfileRead from './profile-read.js'
import PublicRead from './public-read.js'
import { useParams } from 'react-router-dom'
import SlpTokensDisplay from './slp-tokens-display'
function NostrRead (props) {
function Profile (props) {
const [profile, setProfile] = useState(false)
const { npub } = useParams()
console.log('npub', npub)
@@ -18,9 +19,10 @@ function NostrRead (props) {
<Container>
<ProfileRead {...props} setProfile={setProfile} npub={npub} />
<PublicRead {...props} profile={profile} npub={npub} />
<SlpTokensDisplay {...props} npub={npub} />
</Container>
</>
)
}
export default NostrRead
export default Profile
@@ -0,0 +1,114 @@
/**
* Component for read nostr information kind 1
*/
// 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 NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
function PublicRead (props) {
const { npub } = props
const { bchWalletState } = props.appData
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
// Get Last post from a author
const start = () => {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
console.log('pubhex', pubHex)
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] })
setLoaded(true)
})
pool.on('eose', relay => {
console.log('Closing Relay')
setLoaded(true)
relay.close()
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
setPosts(currentPosts => [...currentPosts, ev])
})
}
if (!loaded && npub) {
start()
}
}, [bchWalletState, loaded, npub])
return (
<Container className='mt-4 mb-5'>
{/* Nostr Feed Section */}
<div className='bg-white rounded-1 shadow-sm border mb-4'>
<div className='p-3 border-bottom bg-light rounded-top-4'>
<h5 className='mb-0 fw-bold text-dark'>Nostr Feed</h5>
</div>
<div
className='p-3'
style={{
height: '800px',
overflowY: 'auto',
maxHeight: '800px'
}}
>
{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'>{props.profile?.name}</div>
<small className='text-muted'>
{`${bchWalletState.nostrKeyPair.npub.slice(0, 8)}...${bchWalletState.nostrKeyPair.npub.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' }}>
<NostrFormat content={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>
</div>
</Container>
)
}
export default PublicRead
@@ -0,0 +1,169 @@
/**
* Component for displaying SLP tokens in a grid layout
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Spinner } from 'react-bootstrap'
import config from '../../../../config'
import axios from 'axios'
import TokenCard from '../../slp-tokens/token-card'
function SlpTokensDisplay (props) {
const { appData } = props
const { bchWalletState, wallet } = props.appData
const { npub } = props
const [loaded, setLoaded] = useState(false)
const [tokens, setTokens] = useState([])
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
// 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
}
// Fetch mutable data if it exist and get the token icon url
const fetchTokenIcon = useCallback(async (token) => {
try {
// Get the token data
const tokenData = await appData.wallet.getTokenData(token.tokenId)
if (!tokenData.mutableData) return false // Return false if no mutable data
// Get the token icon from the mutable data
const cid = parseCid(tokenData.mutableData)
console.log('mutable data cid', cid)
const { json } = await appData.wallet.cid2json({ cid })
if (!json) return false
const iconUrl = json.tokenIcon
// Return icon url
return iconUrl
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadTokenIcons = useCallback(async (tokenList) => {
try {
setIconsAreLoaded(false)
// map each token and fetch the icon url
for (let i = 0; i < tokenList.length; i++) {
try {
const thisToken = tokenList[i]
// Incon does not need to be downloaded, so continue with the next one
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const iconUrl = await fetchTokenIcon(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
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
// replace the token in the tokens array
const updatedTokens = [...tokenList]
updatedTokens[i] = thisToken
setTokens(updatedTokens)
} catch (error) {
continue
}
}
setIconsAreLoaded(true)
} catch (error) {
console.warn(error)
setIconsAreLoaded(true)
}
}, [fetchTokenIcon, setTokens])
useEffect(() => {
// setTokens(bchWalletState.slpTokens || [])
const getTokens = async () => {
try {
const { nostrKeyPair } = bchWalletState
if (nostrKeyPair.npub === npub) {
setTokens(bchWalletState.slpTokens)
setLoaded(true)
return
}
setLoaded(false)
const url = `${config.dexServer}/sm/npub/${npub}`
const response = await axios.get(url)
console.log('response', response.data)
const addr = response.data.bchAddr
const tokenList = await wallet.listTokens(addr)
setTokens(tokenList)
// setTokens(response.data)
setLoaded(true)
lazyLoadTokenIcons(tokenList)
} catch (error) {
console.error('Error fetching tokens:', error)
setLoaded(true)
}
}
if (!loaded) {
getTokens()
}
}, [bchWalletState, npub, loaded, lazyLoadTokenIcons, wallet])
return (
<Container>
{!loaded && (
<div className='d-flex justify-content-center align-items-center h-100 mb-4'>
<Spinner />
</div>
)}
{loaded && tokens.length !== 0 &&
<div className='bg-white rounded-1 shadow-sm border mb-4'>
<div className='p-3 border-bottom bg-light rounded-top-4'>
<h5 className='mb-0 fw-bold text-dark'>SLP Tokens</h5>
</div>
<div
className='p-3'
style={{
height: '800px',
overflowY: 'auto',
maxHeight: '800px'
}}
>
<Row>
{!iconsAreLoaded &&
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Icons </span>
<Spinner animation='border' />
</div>
</Col>}
{tokens.map(thisToken => (
<TokenCard
appData={appData}
token={thisToken}
key={`${thisToken.tokenId}`}
refreshTokens={() => { }}
hideSendBtn
/>
))}
</Row>
</div>
</div>}
</Container>
)
}
export default SlpTokensDisplay
@@ -12,7 +12,7 @@ import InfoButton from './info-button'
import SendTokenButton from './send-token-button'
function TokenCard (props) {
const { token } = props
const { token, hideSendBtn } = props
const [icon, setIcon] = useState(token.icon)
// Update icon state every token.icon changes
@@ -64,13 +64,15 @@ function TokenCard (props) {
<Col>
<InfoButton token={props.token} />
</Col>
<Col>
<SendTokenButton
token={props.token}
appData={props.appData}
refreshTokens={props.refreshTokens}
/>
</Col>
{!hideSendBtn && (
<Col>
<SendTokenButton
token={props.token}
appData={props.appData}
refreshTokens={props.refreshTokens}
/>
</Col>
)}
</Row>
</Container>
</Card.Body>
+4 -4
View File
@@ -58,10 +58,10 @@ function NavMenu (props) {
<NavLink
className={currentPath === '/global-feed' ? 'nav-link-active' : 'nav-link-inactive'}
to='/global-feed'
to='/feeds'
onClick={handleClickEvent}
>
Global Feed
Feeds
</NavLink>
<NavLink
className={currentPath === '/nostr-post' ? 'nav-link-active' : 'nav-link-inactive'}
@@ -71,8 +71,8 @@ function NavMenu (props) {
Nostr Post
</NavLink>
<NavLink
className={currentPath === '/nostr-read' ? 'nav-link-active' : 'nav-link-inactive'}
to={`/nostr-read/${bchWalletState?.nostrKeyPair?.npub}`}
className={currentPath === '/profile' ? 'nav-link-active' : 'nav-link-inactive'}
to={`/profile/${bchWalletState?.nostrKeyPair?.npub}`}
onClick={handleClickEvent}
>
Nostr Profile
+1
View File
@@ -15,6 +15,7 @@ const config = {
radicleUrl: 'https://app.radicle.network/seeds/maple.radicle.garden/rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy/remotes/hyyycncbn9qzqmobnhjq9rry6t4mbjiadzjoyhaknzxjcz3cxkpfpc',
dexServer: 'https://dex-api.fullstack.cash',
// dexServer: 'http://localhost:5700',
nostrTopic: 'bch-dex-test-topic-02',
nostrRelay: 'wss://nostr-relay.psfoundation.info'
+7 -3
View File
@@ -14,7 +14,8 @@ function useAppState () {
defaultValue: {
serverUrl: 'https://free-bch.fullstack.cash' // Default server
},
nftData: {}
nftData: {},
lastFeedTab: 'feed'
})
console.log('lsState: ', lsState)
@@ -26,6 +27,7 @@ function useAppState () {
const [servers, setServers] = useState([])
const [dexLib, setDexLib] = useState(false)
const [nostr, setNostr] = useState(false)
const [lastFeedTab, setLastFeedTab] = useState(lsState.lastFeedTab || 'feed')
// Startup state management
const [asyncInitStarted, setAsyncInitStarted] = useState(false)
@@ -53,7 +55,7 @@ function useAppState () {
// console.log('lsState: ', lsState)
const removeLocalStorageItem = removeItem
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.
const newObj = Object.assign({}, lsState, lsObj)
@@ -149,7 +151,9 @@ function useAppState () {
setNostr,
nftForSaleCacheData,
setNftForSaleCacheData,
updateNFTCachedData
updateNFTCachedData,
lastFeedTab,
setLastFeedTab
}
}