Compare commits

..
18 Commits
Author SHA1 Message Date
Chris Troutner 74fbe78cb7 Merge pull request #60 from Permissionless-Software-Foundation/dh-in-chat-menu
feat(nostr): In Chat: clicking on name should open menu
2025-09-02 11:24:39 -07:00
Daniel Gonzalez a38370962a feat(nostr): In Chat: clicking on name should open menu 2025-09-02 13:04:26 -04:00
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
31 changed files with 1956 additions and 398 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
"@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.2.0",
"bch-message-lib": "2.2.1",
+1 -1
View File
@@ -7,7 +7,7 @@
"@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.2.0",
"bch-message-lib": "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,105 @@
/*
Component for displaying channel item
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { 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])
const handleClick = () => {
if (onChangeChannel) {
onChangeChannel(channel)
}
}
return (
<div
className={`d-flex align-items-center p-2 rounded-3 mb-2 cursor-pointer ${selectedChannel === channel ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.2s ease',
backgroundColor: selectedChannel === channel ? '#0d6efd' : 'transparent',
border: '1px solid transparent'
}}
onClick={handleClick}
onMouseEnter={(e) => {
if (selectedChannel !== channel) {
e.currentTarget.style.backgroundColor = '#e9ecef'
e.currentTarget.style.borderColor = '#dee2e6'
}
}}
onMouseLeave={(e) => {
if (selectedChannel !== channel) {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.borderColor = 'transparent'
}
}}
>
{/* Channel Avatar/Icon */}
<div className='me-3 flex-shrink-0 d-flex align-items-center'>
{chInfo?.picture
? (
<img
src={chInfo.picture}
alt={chInfo.name || 'Channel'}
className='rounded-circle'
style={{ width: '32px', height: '32px' }}
/>
)
: (
<div
className='rounded-circle d-flex align-items-center justify-content-center'
style={{
width: '32px',
height: '32px',
backgroundColor: '#e9ecef',
color: '#6c757d',
fontSize: '14px'
}}
>
#
</div>
)}
</div>
{/* Channel Info */}
<div className='flex-grow-1 min-w-0'>
<div
className={`${selectedChannel === channel ? 'text-white' : 'text-dark'} fw-medium`}
style={{ fontSize: '14px' }}
>
{chInfo?.name || getShortName(channel)}
</div>
{!chInfo?.name && (
<div
className={`small ${selectedChannel === channel ? 'text-white-50' : 'text-muted'}`}
style={{ fontSize: '12px' }}
>
Loading channel...
</div>
)}
</div>
{/* Loading indicator */}
{!chInfo?.name && (
<div className='ms-2'>
<Spinner animation='border' size='sm' />
</div>
)}
</div>
)
}
@@ -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 { groupChannels, selectedChannel } = props
if (!groupChannels || groupChannels.length === 0) {
return (
<div className='text-muted small' style={{ fontStyle: 'italic' }}>
No channels available
</div>
)
}
return (
<ListGroup variant='flush' className='bg-transparent'>
{groupChannels.map((channel, i) => (
<ChannelItem key={`channel${i}`} channel={channel} selectedChannel={selectedChannel} {...props} />
))}
</ListGroup>
)
}
export default ChannelList
@@ -0,0 +1,48 @@
/*
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) => {
if (str.length < 20) return str
return str.slice(0, 4) + '...' + str.slice(-4)
}, [])
// 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: '10px', borderRadius: '50%' }} />}
<div className='flex-grow-1'>
<h5 className='mb-1 text-dark'>{getShortName(chInfo?.name || 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,48 @@
/*
Component for the chat sidebar channels
*/
// Global npm libraries
import React from 'react'
// Local libraries
import ChannelList from './channel-list'
import DMList from './dm-list'
function ChatSidebar (props) {
const { dmChannels, profiles } = 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
{...props}
/>
{/* DMs Section */}
<div className='mt-4 pt-4 border-top'>
<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' }}>
DMs
</h5>
</div>
<DMList
dms={dmChannels}
profiles={profiles}
{...props}
/>
</div>
</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
@@ -0,0 +1,113 @@
/*
Component for displaying direct message item
*/
// Global npm libraries
import React, { useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
export default function DMItem (props) {
const { dm, profiles, selectedChannel, onChangeChannel } = props
const { pubKey } = dm
const profile = profiles[pubKey]
const isSelected = selectedChannel === pubKey
const getShortName = useCallback((str) => {
if (str.length < 20) return str
return str.slice(0, 4) + '...' + str.slice(-4)
}, [])
const handleClick = () => {
if (onChangeChannel) {
onChangeChannel(pubKey)
}
}
return (
<div
className={`d-flex align-items-center p-2 rounded-3 mb-2 cursor-pointer ${isSelected ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.2s ease',
backgroundColor: isSelected ? '#0d6efd' : 'transparent',
border: '1px solid transparent'
}}
onClick={handleClick}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = '#e9ecef'
e.currentTarget.style.borderColor = '#dee2e6'
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.borderColor = 'transparent'
}
}}
>
{/* User Avatar */}
<div className='me-3 flex-shrink-0 d-flex align-items-center'>
{profile?.picture
? (
<img
src={profile.picture}
alt={`${profile.name || 'User'} Avatar`}
className='rounded-circle'
style={{ width: '32px', height: '32px' }}
onError={(e) => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
)
: <FontAwesomeIcon
icon={faUser}
className='rounded-circle d-flex align-items-center justify-content-center'
style={{
width: '32px',
height: '32px',
backgroundColor: profile?.picture ? 'transparent' : '#e9ecef',
color: profile?.picture ? 'transparent' : '#6c757d',
fontSize: '14px',
display: profile?.picture ? 'none' : 'flex'
}}
/>}
</div>
{/* User Info */}
<div className='flex-grow-1 min-w-0'>
<div
className={`${isSelected ? 'text-white' : 'text-dark'} ${dm.unreadCount > 0 ? 'fw-bold' : 'fw-medium'}`}
style={{ fontSize: '14px' }}
>
{getShortName(profile?.name) || 'Unknown User'}
</div>
<div
className={`small ${isSelected ? 'text-white-50' : 'text-muted'} ${dm.unreadCount > 0 ? 'fw-bold' : ''}`}
style={{ fontSize: '12px' }}
>
No messages yet
</div>
</div>
{/* Unread indicator */}
{/* {dm.unreadCount > 0 && (
<div className='ms-2'>
<span
className='badge rounded-pill'
style={{
backgroundColor: isSelected ? '#ffffff' : '#dc3545',
color: isSelected ? '#0d6efd' : '#ffffff',
fontSize: '10px',
minWidth: '18px'
}}
>
{dm.unreadCount > 99 ? '99+' : dm.unreadCount}
</span>
</div>
)} */}
</div>
)
}
@@ -0,0 +1,44 @@
/*
Component for displaying a list of direct messages
*/
// Global npm libraries
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import DMItem from './dm-item'
function DMList (props) {
const { dmChannels, profiles, selectedChannel, onChangeChannel } = props
if (dmChannels.length === 0) {
return (
<div className='text-center p-3'>
<div className='text-muted mb-2'>
<FontAwesomeIcon icon={faUser} style={{ fontSize: '24px', opacity: 0.5 }} />
</div>
<div className='text-muted small'>No direct messages yet</div>
<div className='text-muted small' style={{ fontSize: '11px' }}>
Start a conversation to see it here
</div>
</div>
)
}
return (
<div className='dm-list'>
{dmChannels.map((pubKey) => (
<DMItem
key={pubKey}
dm={{ pubKey }}
profiles={profiles}
selectedChannel={selectedChannel}
onChangeChannel={onChangeChannel}
{...props}
/>
))}
</div>
)
}
export default DMList
+208
View File
@@ -0,0 +1,208 @@
/*
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 [groupChannels] = useState(config.chatsId)
const [dmChannels, setDmChannels] = useState([])
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
const npub = await appData.nostrQueries.hexToNpub(pubKey)
// add public key formats to profile object
profile.pubKey = pubKey
profile.npub = npub
// 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 < groupChannels.length; i++) {
const ch = groupChannels[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 channels data state
setChannelsData(currentChs => {
const newChs = { ...currentChs }
newChs[ch] = channelData
return newChs
})
setChannelsLoaded(true)
}
}
// loadMessages()
loadChData()
}, [selectedChannel, appData, groupChannels])
const addPrivateMessage = async (profile) => {
try {
const exist = dmChannels.find(val => val === profile.pubKey)
setSelectedChannel(profile.pubKey)
if (exist) return
setDmChannels(currentChs => {
const newChs = [...currentChs]
newChs.push(profile.pubKey)
return newChs
})
setChannelsData(currentChs => {
const newChs = { ...currentChs }
newChs[profile.pubKey] = profile
return newChs
})
setMessages([])
// setSelectedChannel(profile.pubKey)
} catch (error) {
console.warn(error)
}
}
// Reset states on change channel
const onChangeChannel = useCallback((ch) => {
if (selectedChannel === ch) return
setSelectedChannel(ch)
setMessages([])
setLoadedMessages(false)
}, [selectedChannel])
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
groupChannels={groupChannels}
dmChannels={dmChannels}
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}
addPrivateMessage={addPrivateMessage}
{...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, profiles } = 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={profiles[selectedChannel] ? 'Coming soon...' : '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 || profiles[selectedChannel]}
/>
<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 || profiles[selectedChannel]}
>
<FontAwesomeIcon icon={faPaperPlane} />
</Button>
</InputGroup>
</Form>
</div>
)
}
export default MessageInput
@@ -0,0 +1,112 @@
/*
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'
import ProfileMenu from './profile-menu'
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
? (
<ProfileMenu
profile={profile}
{...props}
>
<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'
}}
/>
</ProfileMenu>
)
: (
<ProfileMenu
profile={profile}
{...props}
>
<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'
}}
/>
</ProfileMenu>
)}
</div>
{!profile && <span className='fw-bold text-dark me-2'>{message.pubkey}<Spinner animation='border' size='sm' /></span>}
{profile && profile.name && (
<ProfileMenu
profile={profile}
{...props}
>
<span className='fw-bold text-dark me-2'>
{profile.name}
</span>
</ProfileMenu>
)}
<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 ' style={{ minHeight: '50vh' }}>
<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
@@ -0,0 +1,64 @@
/*
Component for displaying a profile menu
*/
// Global npm libraries
import React from 'react'
import Dropdown from 'react-bootstrap/Dropdown'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faMessage, faUser } from '@fortawesome/free-solid-svg-icons'
function ProfileMenu (props) {
const { profile, children, addPrivateMessage } = props
const handlePrivateMessage = () => {
addPrivateMessage(profile)
}
const handleUserProfile = () => {
const profileUrl = `${window.location.origin}/profile/${profile.npub}#single-view`
window.open(profileUrl, '_blank')
}
return (
<>
<style>
{`
.dropdown-toggle-no-arrow::after {
display: none !important;
}
`}
</style>
<Dropdown>
<Dropdown.Toggle
as='div'
className='d-inline-block dropdown-toggle-no-arrow'
style={{
cursor: 'pointer',
'--bs-btn-padding-x': '0',
'--bs-btn-padding-y': '0'
}}
>
{children}
</Dropdown.Toggle>
<Dropdown.Menu className='shadow-sm' style={{ minWidth: '180px' }}>
<Dropdown.Header className='text-muted fw-bold'>
Profile Options
</Dropdown.Header>
<Dropdown.Item onClick={handlePrivateMessage}>
<FontAwesomeIcon icon={faMessage} className='me-2' />
Private Message
</Dropdown.Item>
<Dropdown.Item onClick={handleUserProfile}>
<FontAwesomeIcon icon={faUser} className='me-2' />
User Profile
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>
)
}
export default ProfileMenu
@@ -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
@@ -34,79 +33,17 @@ function ContentCreators (props) {
// 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 = []
let closedRelays = 0
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 => {
relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === config.nostrRelays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
// console.log('Received event:', ev)
// Merge list received from all relays
list = [...list, ...ev.tags]
})
})
// console.log('Follow List', list)
const pubKeyHex = nostrKeyPair.pubHex
const list = await appData.nostrQueries.getFollowList(pubKeyHex)
setFollowList(list)
}, [appData])
// Load profile from nostr relays
// It uses multiple relays. It will exit after the first successful retrieval
// from any relay. If one relay fails, it will move on to the next one.
const loadProfile = useCallback(async (pubKey) => {
// Looking for the profile in each relay sequentially
for (let i = 0; i < config.nostrRelays.length; i++) {
const profile = await new Promise((resolve) => {
const relay = config.nostrRelays[i]
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([relay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubKey] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
resolve(false)
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
// console.log('profile', profile)
console.log(`Profile found for ${pubKey} at ${relay.url}`)
resolve(profile)
} catch (error) {
resolve(false)
}
relay.close()
})
})
// Stop looking for profile if found
if (profile) {
return profile
}
}
}, [])
useEffect(() => {
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)
@@ -116,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]
@@ -137,7 +75,7 @@ function ContentCreators (props) {
loadCreators()
getFollowList()
}
}, [loaded, followList, getFollowList, loadProfile])
}, [loaded, getFollowList, appData])
const filteredCreators = useCallback(() => {
if (!creators || creators.length === 0) {
@@ -12,7 +12,6 @@ import * as nip19 from 'nostr-tools/nip19'
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'
@@ -20,7 +19,7 @@ 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])
@@ -33,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)
@@ -122,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 = '-' }
@@ -134,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)
@@ -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' }}>
+5 -87
View File
@@ -3,14 +3,12 @@
*/
// 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
@@ -20,92 +18,12 @@ function Feeds (props) {
const [loaded, setLoaded] = useState(false)
const [profiles, setProfiles] = useState({})
// Load profile from nostr relays
// It uses multiple relays. It will exit after the first successful retrieval
// from any relay. If one relay fails, it will move on to the next one.
const fetchProfile = useCallback(async (pubKey) => {
// Looking for the profile in each relay sequentially
for (let i = 0; i < config.nostrRelays.length; i++) {
const profile = await new Promise((resolve) => {
const relay = config.nostrRelays[i]
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([relay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubKey] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
resolve(false)
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
// console.log('profile', profile)
console.log(`Profile found for ${pubKey} at ${relay.url}`)
resolve(profile)
} catch (error) {
resolve(false)
}
relay.close()
})
})
// Stop looking for profile if found
if (profile) {
return profile
}
}
}, [])
// Get array of feeds from relays
const fetchFeeds = useCallback(async () => {
let feeds = await new Promise((resolve, reject) => {
let list = []
let closedRelays = 0
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 => {
relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === config.nostrRelays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
// console.log('post retrieved from ', relay.url, ev.sig)
list = [...list, ev]
})
})
// Remove duplicated feeds
feeds = feeds.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
// Sort from newest to oldest
feeds.sort((a, b) => b.created_at - a.created_at)
// console.log('feeds', feeds)
setPosts(feeds)
return feeds
}, [])
// Load data on component mount.
useEffect(() => {
const loadData = async () => {
// Get feeds
const feeds = await fetchFeeds()
const feeds = await appData.nostrQueries.getGlobalFeeds()
setPosts(feeds)
setLoaded(true)
const loadedProfiles = [] // fetched profiles ( this will be used for prevent load the same profile multiple times.)
@@ -116,7 +34,7 @@ function Feeds (props) {
const exist = loadedProfiles.find((val) => { return val === pubKey })
if (exist) { continue }
// Fech profile.
const profile = await fetchProfile(pubKey)
const profile = await appData.nostrQueries.getProfile(pubKey)
loadedProfiles.push(pubKey) // mark as loaded
// Update profile state
@@ -130,7 +48,7 @@ function Feeds (props) {
if (!loaded) {
loadData()
}
}, [loaded, fetchFeeds, 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)
@@ -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) => {
@@ -4,7 +4,7 @@
*/
// Global npm libraries
import React, { useEffect, useState, useCallback } from 'react'
import React, { useEffect, useState } from 'react'
import { Container, Button } from 'react-bootstrap'
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
@@ -12,72 +12,33 @@ 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 { npub, appData } = props
const { onProfileRead } = props
const [profile, setProfile] = useState({})
const [loaded, setLoaded] = useState(false)
const [imageError, setImageError] = useState({ picture: false, banner: false })
// Load profile from nostr relays
// It uses multiple relays. It will exit after the first successful retrieval
// from any relay. If one relay fails, it will move on to the next one.
const fetchProfile = useCallback(async (pubKey) => {
console.log('fetching profile for pubkey ', pubKey)
// Looking for the profile in each relay sequentially
for (let i = 0; i < config.nostrRelays.length; i++) {
const profile = await new Promise((resolve) => {
const relay = config.nostrRelays[i]
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([relay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubKey] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
resolve(false)
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
// console.log('profile', profile)
console.log(`Profile found for ${pubKey} at ${relay.url}`)
resolve(profile)
} catch (error) {
resolve(false)
}
relay.close()
})
})
// Stop looking for profile if found
if (profile) {
return profile
}
}
}, [])
useEffect(() => {
const start = async () => {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
const profile = await fetchProfile(pubHex)
onProfileRead(profile)
setProfile(profile)
const profile = await appData.nostrQueries.getProfile(pubHex)
if (profile) {
onProfileRead(profile)
setProfile(profile)
}
setLoaded(true)
}
if (!loaded && npub) {
start()
}
}, [loaded, onProfileRead, npub, fetchProfile])
}, [loaded, onProfileRead, npub, appData])
const handleImageError = (type) => {
setImageError(prev => ({ ...prev, [type]: true }))
@@ -2,78 +2,34 @@
* Component for read nostr information kind 1
*/
// Global npm libraries
import React, { useEffect, useState, useCallback } from 'react'
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)
const getUserFeeds = useCallback(async () => {
let feeds = await new Promise((resolve) => {
let list = []
let closedRelays = 0
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] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === config.nostrRelays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
list = [...list, ev]
})
})
console.log('feeds', feeds)
// Remove duplicated feeds
feeds = feeds.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
// Sort from newest to oldest
feeds.sort((a, b) => b.created_at - a.created_at)
setPosts(feeds)
}, [npub])
useEffect(() => {
// Get Last post from a author
const start = async () => {
const pubKeyHex = appData.nostrQueries.npubToHex(npub)
const feeds = await appData.nostrQueries.getUserFeeds(pubKeyHex)
setPosts(feeds)
setLoaded(true)
await getUserFeeds()
setLoaded(false)
}
if (!loaded && npub) {
start()
}
}, [bchWalletState, loaded, npub, getUserFeeds])
}, [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
}
}
+357
View File
@@ -0,0 +1,357 @@
/**
* 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
}
hexToNpub (hex) {
return nip19.npubEncode(hex)
}
// 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)
}
}