mirror of
https://github.com/Permissionless-Software-Foundation/bch-dex-taker-v2.git
synced 2026-09-21 16:52:01 -07:00
Merge pull request #37 from Permissionless-Software-Foundation/dh-feed-tabs
feat(nostr): Added tabs to Feeds page
This commit is contained in:
@@ -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} />} />
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Component for reading global 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'
|
||||||
@@ -61,7 +61,7 @@ function FeedCard (props) {
|
|||||||
{!profile?.name && (
|
{!profile?.name && (
|
||||||
<span>
|
<span>
|
||||||
{post.pubkey.slice(0, 8) + '...'}
|
{post.pubkey.slice(0, 8) + '...'}
|
||||||
<Spinner animation='border' size='sm' className='ms-2' />
|
{!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
|
||||||
@@ -58,10 +58,10 @@ function NavMenu (props) {
|
|||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
className={currentPath === '/global-feed' ? 'nav-link-active' : 'nav-link-inactive'}
|
className={currentPath === '/global-feed' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||||
to='/global-feed'
|
to='/feeds'
|
||||||
onClick={handleClickEvent}
|
onClick={handleClickEvent}
|
||||||
>
|
>
|
||||||
Global Feed
|
Feeds
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
className={currentPath === '/nostr-post' ? 'nav-link-active' : 'nav-link-inactive'}
|
className={currentPath === '/nostr-post' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||||
|
|||||||
+7
-3
@@ -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)
|
||||||
@@ -53,7 +55,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 +151,9 @@ function useAppState () {
|
|||||||
setNostr,
|
setNostr,
|
||||||
nftForSaleCacheData,
|
nftForSaleCacheData,
|
||||||
setNftForSaleCacheData,
|
setNftForSaleCacheData,
|
||||||
updateNFTCachedData
|
updateNFTCachedData,
|
||||||
|
lastFeedTab,
|
||||||
|
setLastFeedTab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user