Compare commits

..
30 Commits
Author SHA1 Message Date
Chris Troutner 88bf7e5f9a Merge pull request #59 from Permissionless-Software-Foundation/dh-channel-pfp
feat(nostr): Added channel PFPs
2025-08-31 06:28:24 -07:00
Daniel Gonzalez afa208d0d5 Merge branch 'master' of github.com:Permissionless-Software-Foundation/bch-dex-taker-v2 into dh-channel-pfp 2025-08-30 15:59:55 -04:00
Daniel Gonzalez 36c709f580 feat(nostr): Added channel PFPs 2025-08-30 15:02:10 -04:00
Chris Troutner d459e034cc Merge pull request #58 from Permissionless-Software-Foundation/dh-chat-markdown
feat(nostr): Added markdown support to Nostr Chat
2025-08-30 05:18:25 -07:00
Daniel Gonzalez 18584ba2ff feat(nostr): Added markdown support to Nostr Chat 2025-08-29 11:32:58 -04:00
Chris Troutner 88581290c1 Merge pull request #57 from Permissionless-Software-Foundation/ct-unstable
Minor code readability edits
2025-08-28 08:34:53 -07:00
Chris Troutner f640ec0921 Updating package.json 2025-08-28 08:33:45 -07:00
Chris Troutner d244f45897 More minor readability edits 2025-08-28 08:32:44 -07:00
Chris Troutner af27967415 minor code readability edits 2025-08-28 08:13:44 -07:00
Chris Troutner 9055cbcc5d Merge pull request #56 from Permissionless-Software-Foundation/dh-chat-UI
feat(nostr): Created nostr general chat interface
2025-08-28 06:45:57 -07:00
Daniel Gonzalez 574581b9f1 removed testing mock data file 2025-08-27 17:57:47 -04:00
Daniel Gonzalez 1ff9152718 feat(nostr): Created nostr general chat interface 2025-08-27 17:49:16 -04:00
Chris Troutner 8607ce4f15 Merge pull request #55 from Permissionless-Software-Foundation/dh-relay-selection
feat(nostr): Added relay selection to Configuration page
2025-08-23 11:48:02 -07:00
Daniel Gonzalez e18addff30 feat(nostr): Added relay selection to Configuration page 2025-08-23 14:24:24 -04:00
Chris Troutner faec078403 Merge pull request #54 from Permissionless-Software-Foundation/dh-nostr-library
feat(nostr): Created Nostr service library
2025-08-21 07:19:00 -07:00
Daniel Gonzalez 416941089b feat(nostr): Created Nostr service library 2025-08-20 20:39:05 -04:00
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
Chris Troutner a7c3fc243b Merge pull request #51 from Permissionless-Software-Foundation/dh-nostr-relays
feat(nostr): Handle multiple nostr relays
2025-08-13 08:04:01 -07:00
Chris Troutner 173cda717e Adding comments 2025-08-13 08:02:50 -07:00
Chris Troutner e46068165f Removing completed todos 2025-08-13 08:00:52 -07:00
Daniel Gonzalez 086deb4904 feat(nostr): Handle multiple nostr relays 2025-08-12 18:51:33 -04:00
Chris Troutner 5386a07a62 Merge pull request #50 from Permissionless-Software-Foundation/ct-unstable
fix(bch-dex-lib): Updating to v2.2.0
2025-08-10 11:08:07 -07:00
Chris Troutner a240fab588 fix(bch-dex-lib): Updating to v2.2.0 2025-08-10 11:04:58 -07:00
Chris Troutner 0ab5702b48 Merge pull request #49 from Permissionless-Software-Foundation/ct-unstable
fix(feeds): fixing duplicate issues. Adding TODO item
2025-08-08 08:09:07 -07:00
Chris Troutner 97dd816330 fix(feeds): fixing duplicate issues. Adding TODO item 2025-08-08 08:07:53 -07:00
29 changed files with 1678 additions and 371 deletions
+5 -5
View File
@@ -13,9 +13,9 @@
"@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "^1.8.0",
"@noble/hashes": "1.8.0",
"axios": "0.27.2",
"bch-dex-lib": "2.0.3",
"bch-dex-lib": "2.2.0",
"bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1",
@@ -10043,9 +10043,9 @@
"license": "MIT"
},
"node_modules/bch-dex-lib": {
"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==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/bch-dex-lib/-/bch-dex-lib-2.2.0.tgz",
"integrity": "sha512-SORqn4qNmbxfL2SbbapOpbcCB+FtqasR+GT0XRifdkXFjA3aDL0gwMsHjh8IzLZnKtPYSBcfgzKQFxEsXCJNug==",
"license": "MIT",
"dependencies": {
"@chris.troutner/retry-queue": "1.0.10",
+2 -2
View File
@@ -7,9 +7,9 @@
"@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "^1.8.0",
"@noble/hashes": "1.8.0",
"axios": "0.27.2",
"bch-dex-lib": "2.0.3",
"bch-dex-lib": "2.2.0",
"bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1",
@@ -6,6 +6,7 @@
// Global npm libraries
import React from 'react'
import ServerSelectView from './select-server-view'
import RelaySelectionView from './relay-selection-view'
function ConfigurationView (props) {
const { appData } = props
@@ -13,6 +14,7 @@ function ConfigurationView (props) {
return (
<>
<ServerSelectView appData={appData} />
<RelaySelectionView appData={appData} />
</>
)
}
@@ -0,0 +1,333 @@
/*
This component is a View that allows the user to manage Nostr relay settings.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Row, Col, Form, Card, Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faShareAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
function RelaySelectionView (props) {
const { appData } = props
const { relaysData, updateRelaysData, restoreRelaysData } = appData
console.log('relaysData', relaysData)
const [newRelayAddress, setNewRelayAddress] = useState('')
const addRelay = (newRelay) => {
const exist = relaysData.find(relay => relay.address === newRelay)
if (!exist) {
relaysData.push({ address: newRelay, read: true, write: true })
updateRelaysData(relaysData)
}
}
// Delete relay from relaysData array
const deleteRelay = (relayToDelete) => {
const index = relaysData.findIndex(relay => relay.address === relayToDelete.address)
if (index !== -1) {
relaysData.splice(index, 1)
updateRelaysData(relaysData)
}
}
const handleReadToggle = (relayToToggleRead) => {
const index = relaysData.findIndex(relay => relay.address === relayToToggleRead.address)
if (index !== -1) {
relaysData[index].read = !relaysData[index].read
updateRelaysData(relaysData)
}
}
const handleWriteToggle = (relayToToggleWrite) => {
const index = relaysData.findIndex(relay => relay.address === relayToToggleWrite.address)
if (index !== -1) {
relaysData[index].write = !relaysData[index].write
updateRelaysData(relaysData)
}
}
return (
<>
<Card className='m-3'>
<Card.Body>
<Row>
<Col style={{ textAlign: 'center' }}>
<h2>Nostr Relay Configuration</h2>
<p>
Manage your Nostr relay connections. Configure read and write permissions for each relay.
</p>
</Col>
</Row>
<hr />
<Row className='mb-3'>
<Col className='text-end'>
<Button
onClick={() => restoreRelaysData()}
variant='outline-secondary'
style={{ minWidth: '120px' }}
>
Restore defaults
</Button>
</Col>
</Row>
<Row>
<Col>
<div
className='relay-table-container' style={{
backgroundColor: 'transparent',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
>
<div
className='relay-table-header' style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
paddingBottom: '15px',
borderBottom: '2px solid #e9ecef'
}}
>
<h5 style={{ margin: 0, color: '#495057', fontWeight: '600' }}>Relay Connections</h5>
<span style={{
fontSize: '0.85em',
color: '#6c757d',
backgroundColor: '#e9ecef',
padding: '4px 12px',
borderRadius: '20px'
}}
>
{relaysData.length} relays
</span>
</div>
<div className='relay-list'>
{relaysData.map((relay, index) => (
<div
key={`relay-${index}`} style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '16px',
marginBottom: '12px',
border: '1px solid #e9ecef',
transition: 'all 0.2s ease',
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '12px'
}}
>
{/* Relay Address */}
<div style={{ flex: '1', minWidth: '200px' }}>
<div style={{
fontFamily: 'monospace',
fontSize: '0.9em',
color: '#495057',
fontWeight: '500',
wordBreak: 'break-all'
}}
>
{relay.address}
</div>
</div>
{/* Controls */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
flexWrap: 'wrap'
}}
>
{/* Read/Write Toggles */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
backgroundColor: '#f8f9fa',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #dee2e6'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
fontSize: '0.8em',
color: '#6c757d',
fontWeight: '500',
minWidth: '35px'
}}
>
Read
</span>
<Form.Check
type='switch'
onClick={() => handleReadToggle(relay)}
checked={relay.read}
className='mb-0'
style={{ margin: 0 }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
fontSize: '0.8em',
color: '#6c757d',
fontWeight: '500',
minWidth: '35px'
}}
>
Write
</span>
<Form.Check
type='switch'
onClick={() => handleWriteToggle(relay)}
checked={relay.write}
className='mb-0'
style={{ margin: 0 }}
/>
</div>
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '8px' }}>
<Button
variant='outline-primary'
size='sm'
className='d-flex align-items-center gap-1'
style={{
minWidth: '65px',
fontSize: '0.8em',
padding: '4px 8px',
borderWidth: '1px'
}}
>
<FontAwesomeIcon icon={faShareAlt} size='xs' />
Share
</Button>
<Button
onClick={() => deleteRelay(relay)}
variant='outline-danger'
size='sm'
className='d-flex align-items-center gap-1'
style={{
minWidth: '65px',
fontSize: '0.8em',
padding: '4px 8px',
borderWidth: '1px'
}}
>
<FontAwesomeIcon icon={faTrashAlt} size='xs' />
Delete
</Button>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</Col>
</Row>
<Row className='mt-4'>
<Col>
<div
className='add-relay-container' style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '20px',
border: '1px solid #e9ecef',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
>
<div
className='add-relay-header' style={{
marginBottom: '16px',
paddingBottom: '12px',
borderBottom: '2px solid #e9ecef'
}}
>
<h6 style={{
margin: 0,
color: '#495057',
fontWeight: '600',
fontSize: '1rem'
}}
>
Add New Relay
</h6>
<p style={{
margin: '4px 0 0 0',
fontSize: '0.85em',
color: '#6c757d'
}}
>
Enter the URL of a Nostr relay to add it to your configuration
</p>
</div>
<div
className='add-relay-form' style={{
display: 'flex',
gap: '12px',
alignItems: 'end'
}}
>
<div style={{ flex: '1' }}>
<Form.Label
htmlFor='newRelayAddress'
style={{
fontSize: '0.9em',
fontWeight: '500',
color: '#495057',
marginBottom: '8px'
}}
>
Relay Address
</Form.Label>
<Form.Control
type='text'
id='newRelayAddress'
placeholder='wss://your-relay-address.com'
value={newRelayAddress}
onChange={(e) => setNewRelayAddress(e.target.value)}
style={{
fontFamily: 'monospace',
fontSize: '0.9em',
border: '1px solid #dee2e6',
borderRadius: '6px',
padding: '10px 12px'
}}
/>
</div>
<Button
onClick={() => addRelay(newRelayAddress)}
variant='primary'
style={{
minWidth: '80px',
height: '38px',
borderRadius: '6px',
fontSize: '0.9em',
fontWeight: '500',
padding: '8px 16px'
}}
>
Add
</Button>
</div>
</div>
</Col>
</Row>
</Card.Body>
</Card>
</>
)
}
export default RelaySelectionView
+5 -3
View File
@@ -15,20 +15,21 @@ import GetBalance from './balance'
import Wallet from './bch-wallet'
import Placeholder2 from './placeholder2'
import Placeholder3 from './placeholder3'
// import ServerSelectView from './servers/select-server-view'
import ServerSelectView from './configuration/select-server-view.js'
// import SelectServerButton from './servers/select-server-button'
import NftsForSale from './nfts-for-sale'
import BchSend from './bch-send'
import SlpTokens from './slp-tokens'
import SweepWif from './sweep/index.js'
import SignMessage from './sign/index.js'
import ServerSelectView from './configuration/select-server-view'
import ConfigurationView from './configuration/index.js'
import NostrPost from './nostr/nostr-post/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'
import NostrChat from './nostr-chat'
function AppBody (props) {
// Dependency injection through props
const appData = props.appData
@@ -47,13 +48,14 @@ function AppBody (props) {
<Route path='/servers' element={<ServerSelectView appData={appData} />} />
<Route path='/sweep' element={<SweepWif appData={appData} />} />
<Route path='/sign' element={<SignMessage appData={appData} />} />
<Route path='/configuration' element={<ServerSelectView appData={appData} />} />
<Route path='/configuration' element={<ConfigurationView appData={appData} />} />
<Route path='/nostr-post' element={<NostrPost 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} />} />
<Route path='/nostr-chat' element={<NostrChat appData={appData} />} />
</Routes>
{/** Show in all paths except the servers view */}
{/* {appData.currentPath !== '/servers' && <SelectServerButton linkTo='/servers' appData={appData} />} */}
@@ -0,0 +1,59 @@
/*
Component for displaying channel item
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { ListGroup, Spinner } from 'react-bootstrap'
export default function ChannelItem (props) {
const { channel, selectedChannel, onChangeChannel, channelsData } = props
const [chInfo, setChInfo] = useState()
const getShortName = useCallback((str) => {
return str.slice(0, 8) + '...' + str.slice(-5)
}, [])
useEffect(() => {
if (!chInfo && channelsData[channel]) {
setChInfo(channelsData[channel])
}
}, [chInfo, channelsData, channel])
return (
<ListGroup.Item
onClick={() => { onChangeChannel(channel) }}
key={channel}
className='border-0 bg-transparent text-dark'
style={{
cursor: 'pointer',
backgroundColor: selectedChannel === channel ? '#6f42c1' : 'transparent',
borderRadius: '8px',
marginBottom: '4px',
padding: '10px 12px',
transition: 'all 0.2s ease',
border: selectedChannel === channel ? 'none' : '1px solid transparent',
boxShadow: selectedChannel === channel ? '0 2px 4px rgba(111, 66, 193, 0.15)' : 'none'
}}
>
{chInfo?.name &&
<div className='d-flex align-items-center'>
{chInfo.picture && <img src={chInfo.picture} alt={chInfo.name} style={{ width: '30px', marginRight: '5px' }} />}
<span
className='fw-medium'
style={{
fontSize: '14px',
color: selectedChannel === channel ? '#444444' : '#495057',
fontWeight: selectedChannel === channel ? '600' : '500'
}}
>
{chInfo?.name || getShortName(channel)}
</span>
</div>}
{!chInfo?.name &&
<div className='d-flex align-items-center'>
{getShortName(channel)} <Spinner animation='border' size='sm' style={{ marginLeft: '5px' }} />
</div>}
</ListGroup.Item>
)
}
@@ -0,0 +1,29 @@
/*
Component for displaying the list of available channels
*/
// Global npm libraries
import React from 'react'
import { ListGroup } from 'react-bootstrap'
import ChannelItem from './channel-item'
function ChannelList (props) {
const { channels, selectedChannel } = props
if (!channels || channels.length === 0) {
return (
<div className='text-muted small' style={{ fontStyle: 'italic' }}>
No channels available
</div>
)
}
return (
<ListGroup variant='flush' className='bg-transparent'>
{channels.map((channel, i) => (
<ChannelItem key={`channel${i}`} channel={channel} selectedChannel={selectedChannel} {...props} />
))}
</ListGroup>
)
}
export default ChannelList
@@ -0,0 +1,47 @@
/*
Component for the chat header displaying chat info
*/
// Global npm libraries
import React, { useState, useCallback, useEffect } from 'react'
import { Spinner } from 'react-bootstrap'
function ChatHeader (props) {
const { selectedChannel, channelsData } = props
const [chInfo, setChInfo] = useState() // channel data
const getShortName = useCallback((str) => {
return str.slice(0, 8) + '...' + str.slice(-5)
}, [])
// Restore ch info
useEffect(() => {
setChInfo(null)
}, [selectedChannel])
// get channel info
useEffect(() => {
if (!chInfo && channelsData[selectedChannel]) {
setChInfo(channelsData[selectedChannel])
}
}, [chInfo, channelsData, selectedChannel])
return (
<div className='p-3 d-flex justify-content-between align-items-center' style={{ backgroundColor: '#ffffff' }}>
{chInfo &&
<div className='d-flex justify-content-center align-items-center'>
{chInfo.picture && <img src={chInfo.picture} alt={chInfo.name} style={{ width: '50px', marginRight: '5px' }} />}
<div className='flex-grow-1'>
<h5 className='mb-1 text-dark'>{chInfo?.name || getShortName(selectedChannel)}</h5>
<small className='text-muted'>{chInfo?.about || ''}</small>
</div>
</div>}
{!chInfo &&
<div className='flex-grow-1 ps-4'>
<Spinner animation='border' size='sm' />
</div>}
</div>
)
}
export default ChatHeader
@@ -0,0 +1,48 @@
/*
Component for the main chat area
*/
// Global npm libraries
import React from 'react'
// Local libraries
import ChatHeader from './chat-header'
import MessageList from './message-list'
import MessageInput from './message-input'
function ChatMain (props) {
const { selectedChannel, messages } = props
return (
<>
{!selectedChannel && (
<div className='h-100 d-flex align-items-center justify-content-center' style={{ backgroundColor: '#f8f9fa' }}>
<div className='text-center text-muted'>
<h4>Select a channel to start chatting</h4>
</div>
</div>
)}
{selectedChannel && (
<div className='h-100 d-flex flex-column' style={{ backgroundColor: '#f8f9fa' }}>
{/* Chat Header */}
<div>
<ChatHeader selectedChannel={selectedChannel} {...props} />
</div>
{/* Messages Area */}
<div className='flex-grow-1 overflow-auto'>
<MessageList messages={messages} {...props} />
</div>
{/* Message Input */}
<div className='border-top border-tertiary'>
<MessageInput {...props} />
</div>
</div>
)}
</>
)
}
export default ChatMain
@@ -0,0 +1,35 @@
/*
Component for the chat sidebar channels
*/
// Global npm libraries
import React from 'react'
// Local libraries
import ChannelList from './channel-list'
function ChatSidebar (props) {
const { channels, selectedChannel } = props
return (
<div className='h-100 d-flex flex-column' style={{ backgroundColor: '#ffffff', borderRight: '1px solid #e9ecef' }}>
{/* Channels Section */}
<div className='flex-grow-1 d-flex flex-column'>
<div className='p-4 flex-grow-1'>
<div className='d-flex justify-content-between align-items-center mb-3'>
<h5 className='mb-0 text-dark fw-semibold' style={{ fontSize: '16px', letterSpacing: '0.5px' }}>
Channels
</h5>
</div>
<ChannelList
channels={channels}
selectedChannel={selectedChannel}
{...props}
/>
</div>
</div>
</div>
)
}
export default ChatSidebar
@@ -0,0 +1,49 @@
/*
Component for displaying date separators between message groups.
*/
// Global npm libraries
import React from 'react'
function DateSeparator (props) {
const { date } = props
// Format date for display
const formatDate = (dateString) => {
const date = new Date(dateString)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === today.toDateString()) {
return 'Today'
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Yesterday'
} else {
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
}
return (
<div className='text-center my-4'>
<div
className='d-inline-block px-3 py-1 rounded-pill'
style={{
backgroundColor: '#e9ecef',
color: '#6c757d',
fontSize: '12px',
fontWeight: '500'
}}
>
{formatDate(date)}
</div>
</div>
)
}
export default DateSeparator
+175
View File
@@ -0,0 +1,175 @@
/*
Component for Nostr Chat functionality
*/
// Global npm libraries
import React, { useCallback, useEffect, useState, useRef } from 'react'
import { Container, Row, Col } from 'react-bootstrap'
import { RelayPool } from 'nostr'
// Local libraries
import ChatSidebar from './chat-sidebar'
import ChatMain from './chat-main'
import config from '../../../config'
function NostrChat (props) {
const { appData } = props
const { nostrQueries } = appData
const [messages, setMessages] = useState([])
const [loadedMessages, setLoadedMessages] = useState(false)
const [profiles, setProfiles] = useState({})
const [channelsData, setChannelsData] = useState({})
const [channelsLoaded, setChannelsLoaded] = useState(false)
const [channels] = useState(config.chatsId)
const [selectedChannel, setSelectedChannel] = useState(config.chatsId[0])
const profilesRef = useRef({})
// Handle read messages
const onMsgRead = useCallback(async (msg) => {
try {
// console.log('onMsgRead() msg: ', msg)
// Update messages list
setMessages(current => {
// ignore existing messages
const exist = current.find(val => val.id === msg.id)
if (exist) return current
const newMsgs = [...current]
newMsgs.push(msg)
// Sort messages by timestamp
newMsgs.sort((a, b) => b.created_at - a.created_at)
return newMsgs.reverse()
})
// Fetch message owner profile
const pubKey = msg.pubkey
const existProfile = profilesRef.current[msg.pubkey]
if (existProfile) {
return
}
console.log(`Trying to get ${pubKey} profile.`)
const defaultProfile = { name: pubKey } // default profile
profilesRef.current[pubKey] = defaultProfile // update ref , to prevent fetch this profile again.
// Fetch profile
const nostrProfile = await appData.nostrQueries.getProfile(pubKey)
const profile = nostrProfile || defaultProfile
// Update profiles state
setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles }
newProfiles[pubKey] = profile
profilesRef.current = newProfiles
return newProfiles
})
} catch (error) {
console.warn(error)
}
}, [appData, profilesRef])
// Handle nostr pool
useEffect(() => {
// fetch messages when channel selected and channel metadata are loaded
if (!selectedChannel || !channelsLoaded) return
const relays = nostrQueries.relays
if (relays.length === 0) {
return
}
const pool = RelayPool(relays)
// const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('REQ', { limit: 10, kinds: [42], '#e': [selectedChannel] })
})
pool.on('eose', relay => {
setLoadedMessages(true)
})
pool.on('event', (relay, subId, ev) => {
console.log('post retrieved from ', relay.url, ev.content)
onMsgRead(ev)
})
return () => {
// Close pool on component unmount or selected channel changes
console.log('Close existing pool')
pool.close()
}
}, [onMsgRead, selectedChannel, nostrQueries, channelsLoaded])
// Load channels data
useEffect(() => {
const loadChData = async () => {
const loadedChannels = []
for (let i = 0; i < channels.length; i++) {
const ch = channels[i]
const exist = loadedChannels.find((val) => { return val === ch })
if (exist) { continue }
// Fech profile.
let channelData = await appData.nostrQueries.getChannelInfo(ch)
console.log('channelData', channelData)
// Set short id as name
if (!channelData) channelData = { name: ch.slice(0, 8) + '...' + ch.slice(-5) }
loadedChannels.push(channelData)
// Update profile state
setChannelsData(currentChs => {
const newChs = { ...currentChs }
newChs[ch] = channelData
return newChs
})
setChannelsLoaded(true)
}
}
// loadMessages()
loadChData()
}, [selectedChannel, appData, channels])
// Reset states on change channel
const onChangeChannel = useCallback((ch) => {
setSelectedChannel(ch)
setMessages([])
setLoadedMessages(false)
}, [])
return (
<>
<Container fluid className='h-100 p-0 mb-5 '>
<Row className='h-100 g-0'>
<Col xs={12} md={4} lg={3} className='h-100'>
<ChatSidebar
channels={channels}
selectedChannel={selectedChannel}
profiles={profiles}
channelsData={channelsData}
onChangeChannel={onChangeChannel}
{...props}
/>
</Col>
<Col xs={12} md={8} lg={9} className='h-100 pe-2'>
<ChatMain
loadedMessages={loadedMessages}
selectedChannel={selectedChannel}
messages={messages}
profiles={profiles}
channelsData={channelsData}
onChangeChannel={onChangeChannel}
{...props}
/>
</Col>
</Row>
</Container>
</>
)
}
export default NostrChat
@@ -0,0 +1,124 @@
/*
Component for the message input area
*/
// Global npm libraries
import React, { useState } from 'react'
import { Form, Button, InputGroup } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPaperPlane } from '@fortawesome/free-solid-svg-icons'
import { finalizeEvent } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import { Relay } from 'nostr-tools/relay'
function MessageInput (props) {
const { appData, selectedChannel } = props
const { bchWalletState, writeRelays } = appData
const [message, setMessage] = useState('')
const [onFetch, setOnFetch] = useState(false)
// Post on nostr network
const handleSubmit = async (e) => {
e.preventDefault()
try {
setOnFetch(true)
const { nostrKeyPair } = bchWalletState
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
// const psf = 'wss://nostr-relay.psfoundation.info'
// Generate a post.
const eventTemplate = {
kind: 42,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', selectedChannel, 'root']],
content: message
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Publish the post to each relay.
for (let i = 0; i < writeRelays.length; i++) {
const relayUrl = writeRelays[i]
try {
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Close the connection to the relay.
relay.close()
} catch (err) {
console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`)
}
}
setMessage('')
setOnFetch(false)
} catch (error) {
console.warn(error)
setOnFetch(false)
}
}
const onChange = (e) => {
setMessage(e.target.value)
}
return (
<div className='p-3' style={{ backgroundColor: '#ffffff' }}>
<Form onSubmit={handleSubmit}>
<InputGroup>
<Form.Control
as='textarea'
rows={3}
placeholder='Type a message...'
className='border-1 bg-transparent text-dark'
style={{
backgroundColor: '#f8f9fa',
border: '4px solid rgb(235, 232, 232)',
borderRadius: '12px',
padding: '12px 16px',
fontSize: '14px',
resize: 'none',
minHeight: '60px',
maxHeight: '120px'
}}
value={message}
onChange={onChange}
disabled={onFetch}
/>
<Button
type='submit'
variant='link'
className='text-dark border-0 bg-transparent ms-2'
style={{
color: '#6c757d',
transition: 'color 0.2s ease',
alignSelf: 'flex-end',
marginBottom: '8px'
}}
title='Send message'
disabled={onFetch}
>
<FontAwesomeIcon icon={faPaperPlane} />
</Button>
</InputGroup>
</Form>
</div>
)
}
export default MessageInput
@@ -0,0 +1,89 @@
/*
Component for displaying a single chat message
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import { Spinner } from 'react-bootstrap'
import NostrFormat from '../../app-body/nostr/nostr-format'
function MessageItem (props) {
const { message, profiles } = props
const [profile, setProfile] = useState(null)
// Format timestamp
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
useEffect(() => {
if (!profile && profiles[message.pubkey]) {
setProfile(profiles[message.pubkey])
}
}, [profiles, message, profile])
return (
<div className='mb-3 d-flex align-items-start'>
{/* Message Content */}
<div className='flex-grow-1'>
<div
className='text-dark p-3 rounded-3'
style={{
backgroundColor: '#ffffff',
border: '1px solid #e9ecef',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
maxWidth: '85%',
wordWrap: 'break-word'
}}
>
<div className='d-flex align-items-center mb-2'>
{/* Avatar */}
<div className='me-2 flex-shrink-0'>
{profile?.picture
? (
<img
src={profile.picture}
alt={`${profile.name} Avatar`}
className='rounded-circle'
style={{ width: '24px', height: '24px' }}
onError={(e) => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'block'
}}
/>
)
: <FontAwesomeIcon
icon={faUser}
className='rounded-circle d-flex align-items-center justify-content-center'
style={{
width: '24px',
height: '24px',
backgroundColor: '#e9ecef',
color: '#6c757d',
fontSize: '12px',
display: message.avatar ? 'none' : 'flex'
}}
/>}
</div>
{!profile && <span className='fw-bold text-dark me-2'>{message.pubkey}<Spinner animation='border' size='sm' /></span>}
{profile && profile.name && <span className='fw-bold text-dark me-2'>{profile.name}</span>}
<small className='text-muted'>{formatTime(message.created_at * 1000)}</small>
</div>
<div style={{ lineHeight: '1.4', wordBreak: 'break-all' }}>
<NostrFormat content={message.content} />
</div>
</div>
</div>
</div>
)
}
export default MessageItem
@@ -0,0 +1,109 @@
/*
Component for displaying the list of chat messages
*/
// Global npm libraries
import React, { useEffect, useState, useCallback, useRef } from 'react'
// Local libraries
import MessageItem from './message-item'
import DateSeparator from './date-separator'
import { Spinner } from 'react-bootstrap'
function MessageList (props) {
const { messages, loadedMessages } = props
const [groupedMessages, setGroupedMessages] = useState([])
const msgContainerRef = useRef()
// Group messages by date
const groupMessagesByDate = useCallback((messages) => {
const grouped = {}
messages.forEach((message, i) => {
const date = new Date(message.created_at * 1000).toDateString()
if (!grouped[date]) {
grouped[date] = []
}
grouped[date].push(message)
})
return grouped
}, [])
useEffect(() => {
const grouped = groupMessagesByDate(messages)
setGroupedMessages(grouped)
// Scroll to end of the message container.
// Wait few seconds before load render.
setTimeout(() => {
if (msgContainerRef.current) {
msgContainerRef.current.scrollTo({
top: msgContainerRef.current.scrollHeight - msgContainerRef.current.clientHeight,
behavior: 'smooth'
})
msgContainerRef.current = null // scroll one time
}
}, 1000)
}, [messages, groupMessagesByDate])
return (
<>
<style>
{`
.hide-scrollbar::-webkit-scrollbar {
width: 0 !important;
display: none !important;
}
.hide-scrollbar {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
}
`}
</style>
<div
className='h-100 overflow-auto hide-scrollbar'
style={{
backgroundColor: '#f8f9fa',
height: 'calc(100vh - 200px)',
maxHeight: 'calc(100vh - 200px)'
}}
>
{loadedMessages && (!messages || messages.length === 0) && (
<div className='h-100 d-flex align-items-center justify-content-center'>
<div className='text-center text-muted'>
<p>No messages yet. Start the conversation!</p>
</div>
</div>
)}
{loadedMessages && messages && messages.length > 0 && (
<div ref={msgContainerRef} className='p-3' style={{ overflowY: 'auto', maxHeight: '50vh' }}>
{Object.entries(groupedMessages).map(([date, dateMessages]) => (
<div key={date}>
<DateSeparator date={date} />
{dateMessages.map((message, index) => (
<MessageItem
key={`${message.id}-${index}`}
message={message}
totalMessages={messages.length}
messageIndex={index}
{...props}
/>
))}
</div>
))}
</div>
)}
{!loadedMessages &&
<div className='p-3' style={{ minHeight: '50vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Spinner animation='border' size='md' />
</div>}
</div>
</>
)
}
export default MessageList
@@ -19,7 +19,6 @@ import axios from 'axios'
// Local libraries
import config from '../../../../config'
import ContentCard from './content-card'
import { RelayPool } from 'nostr'
// Global variables and constants
const SERVER = config.dexServer
@@ -31,66 +30,20 @@ function ContentCreators (props) {
const [followList, setFollowList] = useState([])
// Get the list of profiles followed by the user.
// It aggregates all the followers from all the relays.
const getFollowList = useCallback(async () => {
const list = await new Promise((resolve, reject) => {
let list = []
const { nostrKeyPair } = appData.bchWalletState
const { nostrKeyPair } = appData.bchWalletState
const pool = RelayPool(config.nostrRelays)
// const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [3], authors: [nostrKeyPair.pubHex] })
})
pool.on('eose', relay => {
console.log('Closing 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) => {
console.log('Received event:', ev)
list = ev.tags
resolve(list)
})
})
console.log('Follow List', list)
const pubKeyHex = nostrKeyPair.pubHex
const list = await appData.nostrQueries.getFollowList(pubKeyHex)
setFollowList(list)
}, [appData])
const loadProfile = useCallback(async (pubKey) => {
return new Promise((resolve) => {
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([config.nostrRelay])
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 {
console.log('loadCreators()')
const creatorsRes = await axios.get(`${SERVER}/sm/list/all/0`)
const creators = creatorsRes.data
// console.log('creators', creators)
@@ -100,8 +53,9 @@ function ContentCreators (props) {
for (let i = 0; i < creators.length; i++) {
try {
const creator = creators[i]
const profile = await loadProfile(creator.pubkey)
const profileRes = await appData.nostrQueries.getProfile(creator.pubkey)
let profile = profileRes
if (!profileRes) profile = {}
// the folowing lines should re-render the ContentCard
setCreators(prevCreators => {
const updatedCreators = [...prevCreators]
@@ -121,7 +75,7 @@ function ContentCreators (props) {
loadCreators()
getFollowList()
}
}, [loaded, followList, getFollowList, loadProfile])
}, [loaded, getFollowList, appData])
const filteredCreators = useCallback(() => {
if (!creators || creators.length === 0) {
@@ -1,6 +1,7 @@
/**
* Component for displaying nostr posts
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { Card, Spinner } from 'react-bootstrap'
@@ -8,20 +9,21 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser, faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons'
import { faHeart } from '@fortawesome/free-regular-svg-icons'
import * as nip19 from 'nostr-tools/nip19'
import NostrFormat from '../nostr-format'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import { RelayPool } from 'nostr'
// Local libraries
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import NostrFormat from '../nostr-format'
function FeedCard (props) {
const { post, appData, profiles } = props
const { nostrKeyPair } = appData.bchWalletState
const { nostrKeyPair, writeRelays } = appData.bchWalletState
const [profile, setProfile] = useState(profiles[post.pubkey])
const [npub, setNpub] = useState('')
const [isClicked, setIsClicked] = useState(false)
const [isLiked, setIsLiked] = useState(false)
const [likesCount, setLikesCount] = useState(null)
const [likesFetched, setLikesFetched] = useState(false)
@@ -30,53 +32,19 @@ function FeedCard (props) {
// function to fetch a post likes reaction
const handleLikes = useCallback(async (post, userPubKey) => {
try {
const psf = 'wss://nostr-relay.psfoundation.info'
const res = await new Promise((resolve) => {
let likesCnt = 0
let userLikedCnt = 0
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { kinds: [7], '#e': [post.id] })
})
// Get all post likes events
const likesArr = await appData.nostrQueries.getPostLikes(post.id)
const likesCount = likesArr.length
pool.on('eose', relay => {
relay.close()
resolve({
count: likesCnt,
userLiked: userLikedCnt > 0
})
})
pool.on('event', (relay, subId, ev) => {
try {
// Count likes
if (ev.content === '+') {
likesCnt++
// Count user likes
if (ev.pubkey === userPubKey) {
userLikedCnt++
}
}
// Count dislikes
if (ev.content === '-') {
likesCnt--
// Count user dislikes.
if (ev.pubkey === userPubKey) {
userLikedCnt--
}
}
} catch (error) {
// skip error
}
})
})
setLikesCount(res.count)
setIsLiked(res.userLiked)
// Verify if the users pubkey is in the likes array
const userLikedPost = likesArr.find(val => { return val.pubkey === userPubKey })
setLikesCount(likesCount)
setIsLiked(userLikedPost)
setLikesFetched(true)
} catch (error) {
console.warn(error)
}
}, [])
}, [appData])
const handleProfilePictureError = () => {
setProfilePictureError(true)
@@ -104,13 +72,6 @@ function FeedCard (props) {
}
}, [profiles, post])
// copy to clipboard
const copyToClipboard = useCallback((value) => {
setIsClicked(true)
appData.appUtil.copyToClipboard(value)
setTimeout(() => setIsClicked(false), 200)
}, [appData])
// get short npub
const getShortNpub = useCallback((npub) => {
return npub.slice(0, 8) + '...' + npub.slice(-5)
@@ -126,9 +87,6 @@ function FeedCard (props) {
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
const psf = 'wss://nostr-relay.psfoundation.info'
// Define if like or dislike
let content = '+'
if (isLiked) { content = '-' }
@@ -138,27 +96,32 @@ function FeedCard (props) {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', post.id, psf],
['p', psf, nostrKeyPair.pubHex]
['e', post.id],
['p', nostrKeyPair.pubHex]
],
content
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
writeRelays.map(async (relayUrl) => {
try {
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
console.log(`connected to ${relay.url}`)
// Connect to a relay.
const relay = await Relay.connect(psf)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Close the connection to the relay.
relay.close()
// Close the connection to the relay.
relay.close()
} catch (err) {
console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`)
}
})
await handleLikes(post, nostrKeyPair.pubHex)
setLikesFetched(true)
@@ -168,6 +131,11 @@ function FeedCard (props) {
}
}
const goToProfile = () => {
const profileUrl = `${window.location.origin}/profile/${npub}#single-view`
window.open(profileUrl, '_blank')
}
return (
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'>
@@ -186,20 +154,21 @@ function FeedCard (props) {
src={profile.picture}
alt='Profile'
className='rounded-circle w-100 h-100'
style={{ objectFit: 'cover' }}
style={{ objectFit: 'cover', cursor: 'pointer' }}
onError={handleProfilePictureError}
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 className='flex-grow-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 && (
<span>
<span style={{ cursor: 'pointer' }} onClick={goToProfile}>
{post.pubkey.slice(0, 8) + '...'}
{!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />}
</span>
@@ -211,14 +180,20 @@ function FeedCard (props) {
title='Copy to clipboard'
style={{
cursor: 'pointer',
transform: isClicked ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease',
display: 'inline-block'
display: 'inline-block',
marginRight: '5px'
}}
onClick={() => copyToClipboard(npub)}
onClick={goToProfile}
>
{getShortNpub(npub)}
</span>
<CopyOnClick
walletProp='npub'
appData={props.appData}
value={npub}
/>
</small>
)}
</div>
@@ -4,41 +4,21 @@
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr'
// Local libraries
import config from '../../../../config'
import FeedCard from './feed-card'
function Following (props) {
const { appData, posts, profiles } = props
const { nostrQueries } = appData
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 pool = RelayPool(config.nostrRelays)
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)
})
})
console.log('getFollowingPosts')
const { nostrKeyPair } = appData.bchWalletState
const followingList = await nostrQueries.getFollowList(nostrKeyPair.pubHex)
// Filter posts by following list
let filteredPosts = []
@@ -55,7 +35,7 @@ function Following (props) {
setLoaded(true)
}
getFollowingPosts()
}, [appData, posts])
}, [appData, posts, nostrQueries])
return (
<Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}>
+21 -84
View File
@@ -1,117 +1,54 @@
/*
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
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect } from 'react'
import { Container, Nav, Tab, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr'
// Local libraries
import Feed from './feed'
import Following from './following'
import config from '../../../../config'
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(async (pubkey) => {
// no fetch profile again if it exist
let hasProfileRequest = false
setProfiles(currentProfiles => {
if (currentProfiles[pubkey]) {
hasProfileRequest = true
return currentProfiles
} else {
const newProfiles = { ...currentProfiles }
newProfiles[pubkey] = { loaded: false }
return newProfiles
}
})
if (hasProfileRequest) return
// Load data on component mount.
useEffect(() => {
const loadData = async () => {
// Get feeds
const feeds = await appData.nostrQueries.getGlobalFeeds()
setPosts(feeds)
setLoaded(true)
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubkey] })
})
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
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) {
console.warn(error)
// skip error
}
})
const exist = loadedProfiles.find((val) => { return val === pubKey })
if (exist) { continue }
// Fech profile.
const profile = await appData.nostrQueries.getProfile(pubKey)
loadedProfiles.push(pubKey) // mark as loaded
pool.on('event', (relay, subId, ev) => {
try {
// update profiles data
const profile = JSON.parse(ev.content)
// Update profile state
setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles }
newProfiles[pubkey] = profile
newProfiles[pubKey] = profile
return newProfiles
})
} catch (error) {
// skip error
}
})
}, [])
// Get global feed posts
useEffect(() => {
const start = () => {
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([config.nostrRelay])
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()
loadData()
}
}, [bchWalletState, loaded, fetchProfile])
}, [loaded, appData])
const onChangeTab = (tab) => {
setActiveTab(tab)
@@ -9,13 +9,12 @@ 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'
// Local libraries
import config from '../../../../config'
function ProfilePost (props) {
const { bchWalletState } = props.appData
const { appData } = props
const { bchWalletState, writeRelays } = appData
const [accordionKey, setAccordionKey] = useState(null)
const [onFetch, setOnFetch] = useState(false)
const [formLoaded, setFormLoaded] = useState(false)
@@ -34,40 +33,19 @@ function ProfilePost (props) {
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) {
}
})
const getLastProfilePost = async () => {
const { nostrKeyPair } = appData.bchWalletState
const lastProfile = await appData.nostrQueries.getProfile(nostrKeyPair.pubHex)
if (lastProfile) {
setFormData(lastProfile)
}
setFormLoaded(true)
}
if (!formLoaded) {
getLastPost()
getLastProfilePost()
}
}, [bchWalletState, formLoaded])
}, [appData, formLoaded])
const handleInputChange = (e) => {
const { name, value } = e.target
@@ -111,7 +89,7 @@ function ProfilePost (props) {
console.log('signedEvent: ', signedEvent)
// Publish the post to each relay.
config.nostrRelays.map(async (relayUrl) => {
writeRelays.map(async (relayUrl) => {
try {
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
@@ -11,12 +11,11 @@ import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
// Local libraries
import config from '../../../../config'
function PublicPost (props) {
const [accordionKey, setAccordionKey] = useState('0')
const [onFetch, setOnFetch] = useState(false)
const { bchWalletState } = props.appData
const { bchWalletState, writeRelays } = props.appData
const [formData, setFormData] = useState({
content: ''
})
@@ -64,7 +63,7 @@ function PublicPost (props) {
console.log('signedEvent: ', signedEvent)
// Publish the post to each relay.
config.nostrRelays.map(async (relayUrl) => {
writeRelays.map(async (relayUrl) => {
try {
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
@@ -17,7 +17,7 @@ function Profile (props) {
return (
<>
<Container>
<ProfileRead {...props} setProfile={setProfile} npub={npub} />
<ProfileRead {...props} onProfileRead={setProfile} npub={npub} />
<PublicRead {...props} profile={profile} npub={npub} />
<SlpTokensDisplay {...props} npub={npub} />
<NFTForSale {...props} npub={npub} />
@@ -14,6 +14,7 @@ function NFTForSale (props) {
const [offersAreLoaded, setOffersAreLoaded] = useState(false)
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [dataAreLoaded, setDataAreLoaded] = useState(false)
const [startAsync, setStartAsync] = useState(false)
const getAddressByNpub = useCallback(async () => {
const url = `${config.dexServer}/sm/npub/${npub}`
@@ -24,6 +25,7 @@ function NFTForSale (props) {
// Handler for refresh button
const handleRefresh = () => {
console.log('handle refresh')
loadNftOffers()
}
@@ -218,9 +220,13 @@ function NFTForSale (props) {
// Effect to load NFTs on component mount
useEffect(() => {
console.log('loading nfts for sale')
loadNftOffers()
}, [loadNftOffers])
// Prevent to load data twice
if (!startAsync) {
console.log('loading nfts for sale')
loadNftOffers()
setStartAsync(true)
}
}, [loadNftOffers, startAsync])
// Get Cid from url
const parseCid = (url) => {
@@ -1,6 +1,8 @@
/**
* Component for read nostr information kind 0
* Component to read nostr information kind 0 (normal posts)
*
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Button } from 'react-bootstrap'
@@ -10,44 +12,25 @@ import { faUser, faGlobe } from '@fortawesome/free-solid-svg-icons'
import * as nip19 from 'nostr-tools/nip19'
// Local libraries
import config from '../../../../config'
import NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr'
function ProfileRead (props) {
const { npub } = props
const { setProfile } = props
const [post, setPost] = useState({})
const { npub, appData } = props
const { onProfileRead } = props
const [profile, setProfile] = useState({})
const [loaded, setLoaded] = useState(false)
const [imageError, setImageError] = useState({ picture: false, banner: false })
useEffect(() => {
const start = () => {
const start = async () => {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
const pool = RelayPool(config.nostrRelays)
pool.on('open', relay => {
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)
}
})
const profile = await appData.nostrQueries.getProfile(pubHex)
if (profile) {
onProfileRead(profile)
setProfile(profile)
}
setLoaded(true)
}
@@ -55,7 +38,7 @@ function ProfileRead (props) {
if (!loaded && npub) {
start()
}
}, [loaded, setProfile, npub])
}, [loaded, onProfileRead, npub, appData])
const handleImageError = (type) => {
setImageError(prev => ({ ...prev, [type]: true }))
@@ -68,10 +51,10 @@ function ProfileRead (props) {
return (
<Container>
{/* Banner Section */}
{post.banner && !imageError.banner && (
{profile.banner && !imageError.banner && (
<div className='position-relative'>
<img
src={post.banner}
src={profile.banner}
alt='Profile banner'
className='w-100 rounded-4 shadow-sm'
style={{ height: '200px', objectFit: 'cover' }}
@@ -84,10 +67,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'>
{/* Profile Picture */}
<div style={{ width: '120px', height: '120px' }} className='mb-3 mb-md-0'>
{post.picture && !imageError.picture
{profile.picture && !imageError.picture
? (
<img
src={post.picture}
src={profile.picture}
alt='Profile'
className='rounded-circle shadow w-100 h-100'
style={{ objectFit: 'cover' }}
@@ -111,7 +94,7 @@ function ProfileRead (props) {
{/* 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'>
<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'>
<span className='text-truncate me-2 mb-2 mb-md-0'>
@@ -126,24 +109,24 @@ function ProfileRead (props) {
</div>
{/* About Section */}
{post.about && (
{profile.about && (
<div className='fs-6 text-secondary'>
<NostrFormat content={post.about} />
<NostrFormat content={profile.about} />
</div>
)}
{/* Website Link */}
{post.website && (
{profile.website && (
<div className='mb-3 d-flex align-items-center gap-2'>
<FontAwesomeIcon icon={faGlobe} className='text-muted' size='sm' />
<a
href={post.website.startsWith('http') ? post.website : `https://${post.website}`}
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target='_blank'
rel='noopener noreferrer'
className='text-decoration-none text-muted small'
style={{ wordBreak: 'break-all' }}
>
{post.website}
{profile.website}
</a>
</div>
)}
@@ -6,49 +6,30 @@ 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 { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
// Local libraries
import config from '../../../../config'
import NostrFormat from '../nostr-format'
function PublicRead (props) {
const { npub } = props
const { bchWalletState } = props.appData
const { npub, appData } = props
const { bchWalletState } = appData
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
const [profilePictureError, setProfilePictureError] = 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 pool = RelayPool(config.nostrRelays)
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])
})
const start = async () => {
const pubKeyHex = appData.nostrQueries.npubToHex(npub)
const feeds = await appData.nostrQueries.getUserFeeds(pubKeyHex)
setPosts(feeds)
setLoaded(true)
}
if (!loaded && npub) {
start()
}
}, [bchWalletState, loaded, npub])
}, [bchWalletState, loaded, npub, appData])
const handleProfilePictureError = () => {
setProfilePictureError(true)
+7
View File
@@ -88,6 +88,13 @@ function NavMenu (props) {
>
Content Creators
</NavLink>
<NavLink
className={currentPath === '/nostr-chat' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-chat'
onClick={handleClickEvent}
>
Nostr Chat
</NavLink>
<hr />
+3
View File
@@ -23,6 +23,9 @@ const config = {
'wss://nostr-relay.psfoundation.info',
'wss://nos.lol',
'wss://relay.damus.io'
],
chatsId: [
'32b0ebc01c984008977fbe476e064946990df75525331b24b37bbba5e89f4039'
]
}
+61 -11
View File
@@ -1,25 +1,31 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
// import { useQueryParam, StringParam } from 'use-query-params'
import useLocalStorageState from 'use-local-storage-state'
import AppUtil from '../util'
import NostrQueries from '../services/nostr-queries'
import { useLocation } from 'react-router-dom'
function useAppState () {
const location = useLocation()
// Default local storage object
const localStorageDefault = {
serverUrl: 'https://free-bch.fullstack.cash', // Default server
relays: [
{ address: 'wss://nostr-relay.psfoundation.info', read: true, write: true },
{ address: 'wss://relay.damus.io', read: true, write: true }
],
nftData: {},
lastFeedTab: 'feed'
}
// Load Local storage Data
const [lsState, setLSState, { removeItem }] = useLocalStorageState('bchWalletState-template', {
ssr: true,
defaultValue: {
serverUrl: 'https://free-bch.fullstack.cash' // Default server
},
nftData: {},
lastFeedTab: 'feed'
defaultValue: localStorageDefault
})
console.log('lsState: ', lsState)
// Initialize data states
const [serverUrl, setServerUrl] = useState(lsState.serverUrl) // Default server url
const [menuState, setMenuState] = useState(0)
@@ -27,7 +33,7 @@ function useAppState () {
const [servers, setServers] = useState([])
const [dexLib, setDexLib] = useState(false)
const [nostr, setNostr] = useState(false)
const [lastFeedTab, setLastFeedTab] = useState(lsState.lastFeedTab || 'feed')
const [lastFeedTab, setLastFeedTab] = useState(lsState.lastFeedTab || localStorageDefault.lastFeedTab)
// Startup state management
const [asyncInitStarted, setAsyncInitStarted] = useState(false)
@@ -44,6 +50,14 @@ function useAppState () {
// NFTs for sale stored data to improve performance
const [nftForSaleCacheData, setNftForSaleCacheData] = useState(lsState.nftData || {})
// Relays
const [relaysData, setRelaysData] = useState(lsState.relays || localStorageDefault.relays) // All relays data
const [readRelays, setReadRelays] = useState(relaysData.filter(relay => relay.read).map(relay => relay.address)) // Read relays
const [writeRelays, setWriteRelays] = useState(relaysData.filter(relay => relay.write).map(relay => relay.address)) // Write relays
// Nostr queries service
const nostrQueriesRef = useRef(new NostrQueries({ relays: readRelays }))
// The wallet state makes this a true progressive web app (PWA). As
// balances, UTXOs, and tokens are retrieved, this state is updated.
// properties are enumerated here for the purpose of documentation.
@@ -96,7 +110,7 @@ function useAppState () {
return newBchWalletState
})
// console.log(`New wallet state: ${JSON.stringify(bchWalletState, null, 2)}`)
// console.log(`New wallet state: ${JSON.stringify(bchWalletState, null, 2)}`)
} catch (err) {
console.error('Error in App.js updateBchWalletState()')
throw err
@@ -113,6 +127,35 @@ function useAppState () {
setNftForSaleCacheData(allCacheData) // Update the state
updateLocalStorage({ nftData: allCacheData }) // Update the local storage
}
// Update relays data
function updateRelaysData (relaysData) {
setRelaysData(relaysData)
updateLocalStorage({ relays: relaysData }) // Update the local storage
// get the relay addresses with read set to true
const readRelays = relaysData.filter(relay => relay.read).map(relay => relay.address)
setReadRelays(readRelays)
console.log('readRelays: ', readRelays)
// get the relay addresses with write set to true
const writeRelays = relaysData.filter(relay => relay.write).map(relay => relay.address)
setWriteRelays(writeRelays)
console.log('writeRelays: ', writeRelays)
nostrQueriesRef.current = new NostrQueries({ relays: readRelays })
}
// Restore relays data
function restoreRelaysData () {
const relaysData = [...localStorageDefault.relays] // Create a new array in order to detect changes
setRelaysData(relaysData)
updateLocalStorage({ relays: relaysData })
// get the relay addresses with read set to true
const readRelays = relaysData.filter(relay => relay.read).map(relay => relay.address)
setReadRelays(readRelays)
console.log('readRelays: ', readRelays)
// get the relay addresses with write set to true
const writeRelays = relaysData.filter(relay => relay.write).map(relay => relay.address)
setWriteRelays(writeRelays)
console.log('writeRelays: ', writeRelays)
nostrQueriesRef.current = new NostrQueries({ relays: readRelays })
}
return {
serverUrl,
@@ -156,7 +199,14 @@ function useAppState () {
lastFeedTab,
setLastFeedTab,
isSingleView,
setIsSingleView
setIsSingleView,
nostrQueries: nostrQueriesRef.current,
relaysData,
updateRelaysData,
restoreRelaysData,
readRelays,
writeRelays
}
}
+353
View File
@@ -0,0 +1,353 @@
/**
* Nostr class for query into relay pools
*
*/
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
export default class NostrQueries {
constructor ({ relays }) {
this.relays = relays || []
}
setRelays (relays) {
this.relays = relays
}
npubToHex (npub) {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
return pubHex
}
// 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.
async getProfile (pubHex) {
try {
if (this.relays.length === 0) {
return false
}
for (let i = 0; i < this.relays.length; i++) {
const profile = await new Promise((resolve) => {
const relay = this.relays[i]
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([relay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubHex] })
})
pool.on('eose', 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 ${pubHex} at ${relay.url}`)
resolve(profile)
} catch (error) {
resolve(false)
}
relay.close()
})
pool.on('error', (relay) => {
console.log(`Error fetching ${pubHex} profile. relay connection error :${relay.url} `)
relay.close()
resolve(false)
})
})
// Stop looking for profile if found
if (profile) {
return profile
}
}
} catch (error) {
console.warn(error)
}
}
// Get Feeds by user pubkey
async getUserFeeds (pubHex) {
try {
if (this.relays.length === 0) {
return []
}
let feeds = await new Promise((resolve) => {
let list = []
const closedRelays = []
const pool = RelayPool(this.relays)
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] })
})
pool.on('eose', relay => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
list = [...list, ev]
})
pool.on('error', (relay) => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
} // Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(list)
}
})
})
// 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)
return feeds
} catch (error) {
console.warn(error)
}
}
// Get global feeds
async getGlobalFeeds () {
try {
if (this.relays.length === 0) {
return []
}
let feeds = await new Promise((resolve, reject) => {
let list = []
const closedRelays = []
const pool = RelayPool(this.relays)
// const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('REQ', { limit: 10, kinds: [1], '#t': ['slpdex-socialmedia'] })
})
pool.on('eose', relay => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
console.log('post retrieved from ', relay.url, ev.sig)
list = [...list, ev]
})
pool.on('error', (relay) => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(list)
}
})
})
// 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)
return feeds
} catch (error) {
console.warn(error)
}
}
// Get follow list by pubkey
async getFollowList (pubHex) {
if (this.relays.length === 0) {
return []
}
return new Promise((resolve, reject) => {
let list = []
const closedRelays = []
const pool = RelayPool(this.relays)
// const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [3], authors: [pubHex] })
})
pool.on('eose', relay => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
// console.log('Received event:', ev)
// Merge list received from all relays
list = [...list, ...ev.tags]
})
pool.on('error', (relay) => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(list)
}
})
})
}
// Get event likes
async getPostLikes (postId) {
try {
if (this.relays.length === 0) {
return []
}
let likesRes = await new Promise((resolve) => {
const likes = []
const closedRelays = []
const pool = RelayPool(this.relays)
pool.on('open', relay => {
relay.subscribe('subid', { kinds: [7], '#e': [postId] })
})
pool.on('eose', relay => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(likes)
}
})
pool.on('event', (relay, subId, ev) => {
try {
// Count likes
if (ev.content === '+' || ev.content === '-') {
likes.push(ev)
}
} catch (error) {
// skip error
}
})
pool.on('error', (relay) => {
relay.close()
if (!closedRelays.includes(relay)) {
closedRelays.push(relay)
}
// Resolve list if all relays are closed
if (closedRelays.length === this.relays.length) {
resolve(likes)
}
})
})
// Remove duplicated events
likesRes = likesRes.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
// Get likes
const likesArr = likesRes.filter((val, i, list) => {
return val.content === '+'
})
// Get dislikes
const dislikesArr = likesRes.filter((val, i, list) => {
return val.content === '-'
})
// For every user dislike remove a user like from the array
for (let i = 0; i < dislikesArr.length; i++) {
const disLike = dislikesArr[i]
const likeExist = likesArr.findIndex(val => val.pubkey === disLike.pubkey)
if (likeExist >= 0) likesArr.splice(likeExist, 1)
}
// Return array of likes.
return likesArr
} catch (error) {
console.warn(error)
}
}
async getChannelInfo (channelId) {
try {
if (this.relays.length === 0) {
return false
}
for (let i = 0; i < this.relays.length; i++) {
const info = await new Promise((resolve) => {
const relay = this.relays[i]
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([relay])
pool.on('open', relay => {
relay.subscribe('REQ', { limit: 1, kinds: [41], '#e': [channelId] })
})
pool.on('eose', relay => {
relay.close()
resolve(false)
})
pool.on('event', (relay, subId, ev) => {
try {
const chInfo = JSON.parse(ev.content)
resolve(chInfo)
} catch (error) {
resolve(false)
}
relay.close()
})
pool.on('error', (relay) => {
relay.close()
resolve(false)
})
})
// Stop looking for profile if found
if (info) {
return info
}
}
} catch (error) {
console.warn(error)
}
}
catch (error) {
console.warn(error)
}
}