Compare commits

...
40 Commits
Author SHA1 Message Date
Chris Troutner a81eee2dda Merge pull request #64 from Permissionless-Software-Foundation/dh-user-data-style
fix(userdata): Fixed markdown text styling on user data page
2025-09-16 15:33:49 -07:00
Daniel Gonzalez 1ffba5c210 fix(userdata): Fixed markdown text styling on user data page 2025-09-16 16:18:59 -04:00
Chris Troutner d30e9d6bfd Merge pull request #63 from Permissionless-Software-Foundation/dh-blacklist
feat(nostr): Removed entries from authors on the npub blacklist
2025-09-10 05:36:52 -07:00
Daniel Gonzalez ed56fe9e38 feat(nostr): Removed entries from authors on the npub blacklist 2025-09-09 18:27:27 -04:00
Chris Troutner fa623aa4eb Merge pull request #62 from Permissionless-Software-Foundation/dh-profile-dm
feat(nostr): Added functionality to message button in profile
2025-09-09 07:13:06 -07:00
Daniel Gonzalez 96f874f93d feat(nostr): Added functionality to message button in profile 2025-09-08 13:07:48 -04:00
Chris Troutner a1d31253c6 Merge pull request #61 from Permissionless-Software-Foundation/dh-private-dm
feat(nostr): Added NIP04 private messaging
2025-09-05 07:17:04 -07:00
Daniel Gonzalez 680f0c8662 feat(nostr): Added NIP04 private messaging 2025-09-04 21:18:53 -04:00
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
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
35 changed files with 2702 additions and 373 deletions
+5 -5
View File
@@ -13,9 +13,9 @@
"@fortawesome/free-regular-svg-icons": "6.7.2", "@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2", "@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.2", "@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "^1.8.0", "@noble/hashes": "1.8.0",
"axios": "0.27.2", "axios": "0.27.2",
"bch-dex-lib": "2.0.3", "bch-dex-lib": "2.2.0",
"bch-message-lib": "2.2.1", "bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4", "bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1", "bch-token-sweep": "2.2.1",
@@ -10043,9 +10043,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/bch-dex-lib": { "node_modules/bch-dex-lib": {
"version": "2.0.3", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/bch-dex-lib/-/bch-dex-lib-2.0.3.tgz", "resolved": "https://registry.npmjs.org/bch-dex-lib/-/bch-dex-lib-2.2.0.tgz",
"integrity": "sha512-aPZIlDcRziZBRKRhEuAv6G/skFjZ28+sE30fkoUY8isUIAe3C0YS7yQMoOmGSA6N46/XuhRbfQi0SRQJfrASpA==", "integrity": "sha512-SORqn4qNmbxfL2SbbapOpbcCB+FtqasR+GT0XRifdkXFjA3aDL0gwMsHjh8IzLZnKtPYSBcfgzKQFxEsXCJNug==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@chris.troutner/retry-queue": "1.0.10", "@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-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2", "@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.2", "@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "^1.8.0", "@noble/hashes": "1.8.0",
"axios": "0.27.2", "axios": "0.27.2",
"bch-dex-lib": "2.0.3", "bch-dex-lib": "2.2.0",
"bch-message-lib": "2.2.1", "bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4", "bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1", "bch-token-sweep": "2.2.1",
+170
View File
@@ -55,4 +55,174 @@ input::-webkit-inner-spin-button {
/* Remove input number arrows Firefox */ /* Remove input number arrows Firefox */
input[type=number] { input[type=number] {
--moz-appearance: textfield; --moz-appearance: textfield;
}
/* Markdown Content Styles */
.markdown-content {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
text-align: left;
}
.markdown-content h1 {
font-size: 2.5rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 1.5rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 3px solid #3b82f6;
text-align: left;
}
.markdown-content h2 {
font-size: 2rem;
font-weight: 600;
color: #374151;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.25rem;
border-bottom: 2px solid #e5e7eb;
}
.markdown-content h3 {
font-size: 1.5rem;
font-weight: 600;
color: #4b5563;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
font-size: 1.25rem;
font-weight: 600;
color: #6b7280;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.markdown-content p {
font-size: 1.1rem;
line-height: 1.7;
color: #374151;
margin-bottom: 1.25rem;
text-align: left;
}
.markdown-content ul, .markdown-content ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.markdown-content li {
font-size: 1.1rem;
line-height: 1.6;
color: #374151;
margin-bottom: 0.5rem;
}
.markdown-content blockquote {
border-left: 4px solid #3b82f6;
background: #f8fafc;
padding: 1rem 1.5rem;
margin: 1.5rem 0;
border-radius: 0 8px 8px 0;
font-style: italic;
color: #4b5563;
}
.markdown-content code {
background: #f1f5f9;
color: #e11d48;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
}
.markdown-content pre {
background: #1e293b;
color: #e2e8f0;
padding: 1.5rem;
border-radius: 8px;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid #334155;
}
.markdown-content pre code {
background: transparent;
color: inherit;
padding: 0;
border-radius: 0;
}
.markdown-content a {
color: #3b82f6;
text-decoration: none;
border-bottom: 1px solid transparent;
transition: all 0.2s ease;
}
.markdown-content a:hover {
color: #1d4ed8;
border-bottom-color: #1d4ed8;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.markdown-content th, .markdown-content td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.markdown-content th {
background: #f8fafc;
font-weight: 600;
color: #374151;
}
.markdown-content hr {
border: none;
height: 2px;
background: linear-gradient(90deg, transparent, #e5e7eb, transparent);
margin: 2rem 0;
}
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
margin: 1rem 0;
}
@media (max-width: 768px) {
.markdown-content {
padding: 1.5rem;
margin: 1rem;
}
.markdown-content h1 {
font-size: 2rem;
}
.markdown-content h2 {
font-size: 1.75rem;
}
.markdown-content p, .markdown-content li {
font-size: 1rem;
}
} }
+1
View File
@@ -74,6 +74,7 @@ function App (props) {
console.log('asyncInitStarted: ', appData.asyncInitStarted) console.log('asyncInitStarted: ', appData.asyncInitStarted)
console.log('is single view : ', singleView) console.log('is single view : ', singleView)
await appData.nostrQueries.start()
if (!appData.asyncInitStarted && !singleView) { if (!appData.asyncInitStarted && !singleView) {
try { try {
// Instantiate the async load object. // Instantiate the async load object.
@@ -6,6 +6,7 @@
// Global npm libraries // Global npm libraries
import React from 'react' import React from 'react'
import ServerSelectView from './select-server-view' import ServerSelectView from './select-server-view'
import RelaySelectionView from './relay-selection-view'
function ConfigurationView (props) { function ConfigurationView (props) {
const { appData } = props const { appData } = props
@@ -13,6 +14,7 @@ function ConfigurationView (props) {
return ( return (
<> <>
<ServerSelectView appData={appData} /> <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 Wallet from './bch-wallet'
import Placeholder2 from './placeholder2' import Placeholder2 from './placeholder2'
import Placeholder3 from './placeholder3' 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 SelectServerButton from './servers/select-server-button'
import NftsForSale from './nfts-for-sale' import NftsForSale from './nfts-for-sale'
import BchSend from './bch-send' import BchSend from './bch-send'
import SlpTokens from './slp-tokens' import SlpTokens from './slp-tokens'
import SweepWif from './sweep/index.js' import SweepWif from './sweep/index.js'
import SignMessage from './sign/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 NostrPost from './nostr/nostr-post/index.js'
import Profile from './nostr/profile/index.js' import Profile from './nostr/profile/index.js'
import Feeds from './nostr/feeds/index.js' import Feeds from './nostr/feeds/index.js'
import ContentCreators from './nostr/content-creators/index.js' import ContentCreators from './nostr/content-creators/index.js'
import UserDataReview from './user-data-review' import UserDataReview from './user-data-review'
import Offers from './offers' import Offers from './offers'
import NostrChat from './nostr-chat'
function AppBody (props) { function AppBody (props) {
// Dependency injection through props // Dependency injection through props
const appData = props.appData const appData = props.appData
@@ -47,13 +48,14 @@ function AppBody (props) {
<Route path='/servers' element={<ServerSelectView appData={appData} />} /> <Route path='/servers' element={<ServerSelectView appData={appData} />} />
<Route path='/sweep' element={<SweepWif appData={appData} />} /> <Route path='/sweep' element={<SweepWif appData={appData} />} />
<Route path='/sign' element={<SignMessage 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='/nostr-post' element={<NostrPost appData={appData} />} />
<Route path='/profile/:npub' element={<Profile appData={appData} />} /> <Route path='/profile/:npub' element={<Profile appData={appData} />} />
<Route path='/feeds' element={<Feeds appData={appData} />} /> <Route path='/feeds' element={<Feeds appData={appData} />} />
<Route path='/content-creators' element={<ContentCreators appData={appData} />} /> <Route path='/content-creators' element={<ContentCreators appData={appData} />} />
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} /> <Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
<Route path='/offers' element={<Offers appData={appData} />} /> <Route path='/offers' element={<Offers appData={appData} />} />
<Route path='/nostr-chat' element={<NostrChat appData={appData} />} />
</Routes> </Routes>
{/** Show in all paths except the servers view */} {/** Show in all paths except the servers view */}
{/* {appData.currentPath !== '/servers' && <SelectServerButton linkTo='/servers' appData={appData} />} */} {/* {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,53 @@
/*
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, selectedChannelIsDm, profiles } = 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(() => {
// Public channel
if (!chInfo && !selectedChannelIsDm && channelsData[selectedChannel]) {
setChInfo(channelsData[selectedChannel])
}
// Dm Channel
if (!chInfo && selectedChannelIsDm && profiles[selectedChannel]) {
setChInfo(profiles[selectedChannel])
}
}, [chInfo, channelsData, selectedChannel, profiles, selectedChannelIsDm])
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,49 @@
/*
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'
import { Spinner } from 'react-bootstrap'
function ChatMain (props) {
const { selectedChannel, messages } = props
return (
<>
{!selectedChannel && (
<div className='h-100 d-flex align-items-center justify-content-center' style={{ backgroundColor: '#f8f9fa', minHeight: '200px' }}>
<div className='text-center text-muted'>
<Spinner animation='border' size='sm' />
</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,129 @@
/*
Component for displaying direct message item
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import { Spinner } from 'react-bootstrap'
export default function DMItem (props) {
const { dm, profiles, selectedChannel, onChangeChannel } = props
const { pubKey } = dm
const [profile, setProfile] = useState(profiles[pubKey])
const isSelected = selectedChannel === pubKey
useEffect(() => {
const profile = profiles[pubKey]
if (profile) {
setProfile(profile)
}
}, [profiles, pubKey])
const getShortName = useCallback((str) => {
if (!str || !str.startsWith('npub')) return str
const prefix = 'npub'
const first4 = str.slice(prefix.length, prefix.length + 4)
const last4 = str.slice(-4)
return `${prefix}:${first4}...${last4}`
}, [])
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'}
{!profile?.name && <Spinner animation='border' size='sm' style={{ marginLeft: '5px' }} />}
</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,50 @@
/*
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'
import { Spinner } from 'react-bootstrap'
function DMList (props) {
const { dmChannels, profiles, selectedChannel, onChangeChannel, dmListLoaded } = props
if (dmChannels.length === 0 && dmListLoaded) {
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}
/>
))}
{!dmListLoaded && (
<div className='text-center'>
<Spinner animation='border' />
</div>
)}
</div>
)
}
export default DMList
+440
View File
@@ -0,0 +1,440 @@
/*
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, bchWalletState, startChannelChat } = 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 [dmListLoaded, setDmListLoaded] = useState(false)
const [selectedChannel, setSelectedChannel] = useState(null)
const [selectedChannelIsDm, setSelectedChannelIsDm] = useState(false)
const profilesRef = useRef({})
const dmChannelsRef = useRef([])
// Reset states on change channel
const onChangeChannel = useCallback((ch) => {
if (selectedChannel === ch) return
const profiles = profilesRef.current
setSelectedChannelIsDm(!!profiles[ch])
setMessages([])
setLoadedMessages(false)
setSelectedChannel(ch)
}, [selectedChannel])
// Add a new DM to the list
const addPrivateMessage = useCallback(async (profile) => {
try {
const exist = dmChannelsRef.current.find(val => val === profile.pubKey)
setMessages([])
setLoadedMessages(false)
onChangeChannel(profile.pubKey)
if (exist) return
setDmChannels(currentChs => {
let newChs = [...currentChs]
newChs.push(profile.pubKey)
newChs = newChs.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value === val)
return existingIndex === i
})
dmChannelsRef.current = newChs
return newChs
})
setChannelsData(currentChs => {
const newChs = { ...currentChs }
newChs[profile.pubKey] = profile
return newChs
})
} catch (error) {
console.warn(error)
}
}, [onChangeChannel])
// Define starter chat
useEffect(() => {
// Start dm if a initial chat is provided from props
const startDM = async (pubKey) => {
const npub = await appData.nostrQueries.hexToNpub(pubKey)
const defaultProfile = { name: npub } // 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
// 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
})
await addPrivateMessage(profile)
}
if (selectedChannel) return
if (!startChannelChat) {
// Set group chat as initial chat
setSelectedChannel(config.chatsId[0])
setSelectedChannelIsDm(false)
} else if (dmListLoaded) {
// Set provided profile as initial chat
startDM(startChannelChat)
}
}, [appData, startChannelChat, dmListLoaded, addPrivateMessage, selectedChannel])
// Handle read messages
const onMsgRead = useCallback(async (ev) => {
try {
// console.log('onMsgRead() msg: ', msg)
// Update messages list
setMessages(current => {
// ignore existing messages
const exist = current.find(val => val.id === ev.id)
if (exist) return current
const newMsgs = [...current]
newMsgs.push(ev)
// Sort messages by timestamp
newMsgs.sort((a, b) => b.created_at - a.created_at)
return newMsgs.reverse()
})
// Fetch message owner profile
const pubKey = ev.pubkey
const existProfile = profilesRef.current[ev.pubkey]
if (existProfile) {
return
}
const npub = await appData.nostrQueries.hexToNpub(pubKey)
console.log(`Trying to get ${pubKey} profile.`)
const defaultProfile = { name: npub } // 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
// 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])
const decryptMsg = useCallback(async ({ ev, pubKey }) => {
try {
const encryptedMsg = ev.content
const senderPubKey = pubKey
const { nostrKeyPair } = appData.bchWalletState
const { nostrQueries } = appData
const decryptData = {
receiverPrivKey: nostrKeyPair.privHex,
senderPubKey,
encryptedMsg
}
const decrptedMsg = await nostrQueries.decryptMsg(decryptData)
ev.content = decrptedMsg
onMsgRead(ev)
} catch (error) {
console.warn(error)
}
}, [appData, onMsgRead])
// Handle nostr pool for group channels
useEffect(() => {
// fetch messages when channel selected and channel metadata are loaded
if (!selectedChannel || !channelsLoaded || selectedChannelIsDm) return
// Load messages for group channel
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 => {
if (!selectedChannelIsDm) {
setLoadedMessages(true)
}
})
pool.on('event', (relay, subId, ev) => {
console.log('Group post retrieved from ', relay.url, ev.content)
const onBlackList = nostrQueries.blackList.find((val) => { return val === ev.pubkey })
if (!onBlackList)onMsgRead(ev)
})
return () => {
// Close pool on component unmount or selected channel changes
console.log('Close existing pool for group channel')
pool.close()
}
}, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, channelsLoaded])
// Handle nostr pool for dm channels
useEffect(() => {
// fetch messages when channel selected and channel metadata are loaded
if (!selectedChannel || !selectedChannelIsDm) return
const { nostrKeyPair } = bchWalletState
const dmPubKey = selectedChannel
// Load messages for group channel
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: [4], '#p': [nostrKeyPair.pubHex], authors: [dmPubKey] }, // received messages
{ limit: 10, kinds: [4], '#p': [dmPubKey], authors: [nostrKeyPair.pubHex] } // sent messages
])
})
pool.on('eose', relay => {
if (selectedChannelIsDm) {
setLoadedMessages(true)
}
})
pool.on('event', (relay, subId, ev) => {
console.log('DM post retrieved from ', relay.url, ev.content)
// decrpt message
if (ev.pubkey === nostrKeyPair.pubHex) {
// Sent messages
decryptMsg({ ev, pubKey: dmPubKey })
} else {
// Received messages
decryptMsg({ ev, pubKey: ev.pubkey })
}
})
return () => {
// Close pool on component unmount or selected channel changes
console.log('Close existing pool for private channel')
pool.close()
}
}, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, bchWalletState, decryptMsg])
const handleIncomingDms = useCallback(async (pubKey) => {
try {
if (!dmListLoaded) return
const exist = dmChannelsRef.current.find(val => val === pubKey)
if (exist) return
let profile = await nostrQueries.getProfile(pubKey)
const npub = nostrQueries.hexToNpub(pubKey)
if (!profile) profile = { name: npub }
// add public key formats to profile object
profile.pubKey = pubKey
profile.npub = npub
setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles }
newProfiles[pubKey] = profile
profilesRef.current = newProfiles
return newProfiles
})
setDmChannels(currentChs => {
const newChs = [...currentChs]
newChs.push(profile.pubKey)
dmChannelsRef.current = newChs
return newChs
})
setChannelsData(currentChs => {
const newChs = { ...currentChs }
newChs[profile.pubKey] = profile
return newChs
})
} catch (error) {
console.warn(error)
}
}, [nostrQueries, dmListLoaded])
// Keep live NPI04 for new dms
useEffect(() => {
if (!dmListLoaded || !channelsLoaded) return
const { bchWalletState } = appData
const { nostrKeyPair } = bchWalletState
const relays = nostrQueries.relays
const pool = RelayPool(relays)
// const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('REQ', [
{ limit: 0, kinds: [4], '#p': [nostrKeyPair.pubHex] } // received messages
])
})
pool.on('event', (relay, subId, ev) => {
console.log('New message received', ev)
handleIncomingDms(ev.pubkey)
})
return () => {
// Close pool on component unmount or selected channel changes
console.log('Close existing pool for private channel')
pool.close()
}
}, [handleIncomingDms, appData, nostrQueries, dmListLoaded, channelsLoaded])
// Load Dm channels
useEffect(() => {
const loadCurrentDms = async () => {
const { nostrQueries, bchWalletState } = appData
const { nostrKeyPair } = bchWalletState
const dms = await appData.nostrQueries.getDms(nostrKeyPair.pubHex)
setDmChannels(currentChs => {
let newChs = [...currentChs, ...dms]
newChs = newChs.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value === val)
return existingIndex === i
})
dmChannelsRef.current = newChs
return newChs
})
setDmListLoaded(true)
console.log('dm list', dms)
for (let i = 0; i < dms.length; i++) {
const pubKey = dms[i]
const npub = await nostrQueries.hexToNpub(pubKey)
const defaultProfile = { name: npub } // default profile
profilesRef.current[pubKey] = defaultProfile // update ref , to prevent fetch this profile again.
// Fetch profile
const nostrProfile = await nostrQueries.getProfile(pubKey)
const profile = nostrProfile || defaultProfile
// 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
})
}
}
if (!dmListLoaded) loadCurrentDms()
}, [appData, dmListLoaded])
// Load public 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)
}
}
loadChData()
}, [selectedChannel, appData, groupChannels])
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}
selectedChannelIsDm={selectedChannelIsDm}
profiles={profiles}
channelsData={channelsData}
onChangeChannel={onChangeChannel}
dmListLoaded={dmListLoaded && channelsLoaded}
{...props}
/>
</Col>
<Col xs={12} md={8} lg={9} className='h-100 pe-2'>
<ChatMain
loadedMessages={loadedMessages}
selectedChannel={selectedChannel}
selectedChannelIsDm={selectedChannelIsDm}
messages={messages}
profiles={profiles}
channelsData={channelsData}
dmListLoaded={dmListLoaded && channelsLoaded}
onChangeChannel={onChangeChannel}
addPrivateMessage={addPrivateMessage}
{...props}
/>
</Col>
</Row>
</Container>
</>
)
}
export default NostrChat
@@ -0,0 +1,190 @@
/*
Component for the message input area
*/
// Global npm libraries
import React, { useState, useEffect } 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, nostrQueries } = appData
const [isDm, setIsDm] = useState(false)
const [dmProfile, setDmProfile] = useState(false)
const [message, setMessage] = useState('')
const [onFetch, setOnFetch] = useState(false)
// Define input type between private or public
useEffect(() => {
const dmTo = profiles[selectedChannel]
setIsDm(!!dmTo)
setDmProfile(dmTo)
}, [selectedChannel, profiles])
const handleSubmitPrivate = async (e) => {
e.preventDefault()
try {
setOnFetch(true)
console.log('dm To : ', dmProfile)
const { nostrKeyPair } = bchWalletState
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
const encryptedMsg = await nostrQueries.encryptMsg({
senderPrivKey: nostrKeyPair.privHex,
receiverPubKey: dmProfile?.pubKey,
message
})
console.log('encryptedMsg', encryptedMsg)
// Generate a post.
const eventTemplate = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', dmProfile.pubKey]],
content: encryptedMsg
}
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)
}
}
// Post on nostr network
const handleSubmitPublic = 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={isDm ? handleSubmitPrivate : handleSubmitPublic}>
<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,123 @@
/*
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, selectedChannel } = props
const [profile, setProfile] = useState(null)
const [isDm, setIsDm] = useState(false)
// Define type between private or public
useEffect(() => {
const dmTo = profiles[selectedChannel]
setIsDm(!!dmTo)
}, [selectedChannel, profiles])
// Format timestamp
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
useEffect(() => {
if (profiles[message.pubkey]) {
setProfile(profiles[message.pubkey])
}
}, [profiles, message])
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}
isDm={isDm}
{...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}
isDm={isDm}
{...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}
isDm={isDm}
{...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,110 @@
/*
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
console.log('loadedMessages', loadedMessages)
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,66 @@
/*
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, isDm, dmListLoaded } = 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>
{!isDm && (
<Dropdown.Item onClick={handlePrivateMessage} disabled={!dmListLoaded}>
<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 // Local libraries
import config from '../../../../config' import config from '../../../../config'
import ContentCard from './content-card' import ContentCard from './content-card'
import { RelayPool } from 'nostr'
// Global variables and constants // Global variables and constants
const SERVER = config.dexServer const SERVER = config.dexServer
@@ -31,66 +30,20 @@ function ContentCreators (props) {
const [followList, setFollowList] = useState([]) 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 getFollowList = useCallback(async () => {
const list = await new Promise((resolve, reject) => { const { nostrKeyPair } = appData.bchWalletState
let list = []
const { nostrKeyPair } = appData.bchWalletState
const pool = RelayPool(config.nostrRelays) const pubKeyHex = nostrKeyPair.pubHex
// const pool = RelayPool([config.nostrRelay]) const list = await appData.nostrQueries.getFollowList(pubKeyHex)
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)
setFollowList(list) setFollowList(list)
}, [appData]) }, [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(() => { useEffect(() => {
const loadCreators = async () => { const loadCreators = async () => {
try { try {
console.log('loadCreators()')
const creatorsRes = await axios.get(`${SERVER}/sm/list/all/0`) const creatorsRes = await axios.get(`${SERVER}/sm/list/all/0`)
const creators = creatorsRes.data const creators = creatorsRes.data
// console.log('creators', creators) // console.log('creators', creators)
@@ -100,8 +53,9 @@ function ContentCreators (props) {
for (let i = 0; i < creators.length; i++) { for (let i = 0; i < creators.length; i++) {
try { try {
const creator = creators[i] 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 // the folowing lines should re-render the ContentCard
setCreators(prevCreators => { setCreators(prevCreators => {
const updatedCreators = [...prevCreators] const updatedCreators = [...prevCreators]
@@ -121,7 +75,7 @@ function ContentCreators (props) {
loadCreators() loadCreators()
getFollowList() getFollowList()
} }
}, [loaded, followList, getFollowList, loadProfile]) }, [loaded, getFollowList, appData])
const filteredCreators = useCallback(() => { const filteredCreators = useCallback(() => {
if (!creators || creators.length === 0) { if (!creators || creators.length === 0) {
@@ -1,6 +1,7 @@
/** /**
* Component for displaying nostr posts * Component for displaying nostr posts
*/ */
// Global npm libraries // Global npm libraries
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { Card, Spinner } from 'react-bootstrap' import { Card, Spinner } from 'react-bootstrap'
@@ -8,20 +9,21 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser, faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons' import { faUser, faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons'
import { faHeart } from '@fortawesome/free-regular-svg-icons' import { faHeart } from '@fortawesome/free-regular-svg-icons'
import * as nip19 from 'nostr-tools/nip19' import * as nip19 from 'nostr-tools/nip19'
import NostrFormat from '../nostr-format'
import { finalizeEvent } from 'nostr-tools/pure' import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay' import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import { RelayPool } from 'nostr'
// Local libraries
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import NostrFormat from '../nostr-format'
function FeedCard (props) { function FeedCard (props) {
const { post, appData, profiles } = props const { post, appData, profiles } = props
const { nostrKeyPair } = appData.bchWalletState const { nostrKeyPair, writeRelays } = appData.bchWalletState
const [profile, setProfile] = useState(profiles[post.pubkey]) const [profile, setProfile] = useState(profiles[post.pubkey])
const [npub, setNpub] = useState('') const [npub, setNpub] = useState('')
const [isClicked, setIsClicked] = useState(false)
const [isLiked, setIsLiked] = useState(false) const [isLiked, setIsLiked] = useState(false)
const [likesCount, setLikesCount] = useState(null) const [likesCount, setLikesCount] = useState(null)
const [likesFetched, setLikesFetched] = useState(false) const [likesFetched, setLikesFetched] = useState(false)
@@ -30,53 +32,19 @@ function FeedCard (props) {
// function to fetch a post likes reaction // function to fetch a post likes reaction
const handleLikes = useCallback(async (post, userPubKey) => { const handleLikes = useCallback(async (post, userPubKey) => {
try { try {
const psf = 'wss://nostr-relay.psfoundation.info' // Get all post likes events
const res = await new Promise((resolve) => { const likesArr = await appData.nostrQueries.getPostLikes(post.id)
let likesCnt = 0 const likesCount = likesArr.length
let userLikedCnt = 0
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { kinds: [7], '#e': [post.id] })
})
pool.on('eose', relay => { // Verify if the users pubkey is in the likes array
relay.close() const userLikedPost = likesArr.find(val => { return val.pubkey === userPubKey })
resolve({ setLikesCount(likesCount)
count: likesCnt, setIsLiked(userLikedPost)
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)
setLikesFetched(true) setLikesFetched(true)
} catch (error) { } catch (error) {
console.warn(error) console.warn(error)
} }
}, []) }, [appData])
const handleProfilePictureError = () => { const handleProfilePictureError = () => {
setProfilePictureError(true) setProfilePictureError(true)
@@ -104,13 +72,6 @@ function FeedCard (props) {
} }
}, [profiles, post]) }, [profiles, post])
// copy to clipboard
const copyToClipboard = useCallback((value) => {
setIsClicked(true)
appData.appUtil.copyToClipboard(value)
setTimeout(() => setIsClicked(false), 200)
}, [appData])
// get short npub // get short npub
const getShortNpub = useCallback((npub) => { const getShortNpub = useCallback((npub) => {
return npub.slice(0, 8) + '...' + npub.slice(-5) return npub.slice(0, 8) + '...' + npub.slice(-5)
@@ -126,9 +87,6 @@ function FeedCard (props) {
// Convert private key to binary // Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex) const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
const psf = 'wss://nostr-relay.psfoundation.info'
// Define if like or dislike // Define if like or dislike
let content = '+' let content = '+'
if (isLiked) { content = '-' } if (isLiked) { content = '-' }
@@ -138,27 +96,32 @@ function FeedCard (props) {
kind: 7, kind: 7,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
['e', post.id, psf], ['e', post.id],
['p', psf, nostrKeyPair.pubHex] ['p', nostrKeyPair.pubHex]
], ],
content content
} }
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`) console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post writeRelays.map(async (relayUrl) => {
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin) try {
console.log('signedEvent: ', signedEvent) // 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. // Publish the message to the relay.
const relay = await Relay.connect(psf) const result = await relay.publish(signedEvent)
console.log(`connected to ${relay.url}`) console.log('result: ', result)
// Publish the message to the relay. // Close the connection to the relay.
const result = await relay.publish(signedEvent) relay.close()
console.log('result: ', result) } catch (err) {
console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`)
// Close the connection to the relay. }
relay.close() })
await handleLikes(post, nostrKeyPair.pubHex) await handleLikes(post, nostrKeyPair.pubHex)
setLikesFetched(true) 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 ( return (
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'> <Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'> <Card.Body className='p-3'>
@@ -186,20 +154,21 @@ function FeedCard (props) {
src={profile.picture} src={profile.picture}
alt='Profile' alt='Profile'
className='rounded-circle w-100 h-100' className='rounded-circle w-100 h-100'
style={{ objectFit: 'cover' }} style={{ objectFit: 'cover', cursor: 'pointer' }}
onError={handleProfilePictureError} onError={handleProfilePictureError}
onLoad={handleProfilePictureLoad} onLoad={handleProfilePictureLoad}
onClick={goToProfile}
/> />
) )
: ( : (
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' /> <FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' style={{ cursor: 'pointer' }} onClick={goToProfile} />
)} )}
</div> </div>
<div className='flex-grow-1'> <div className='flex-grow-1'>
<div className='fw-bold mb-1'> <div className='fw-bold mb-1'>
{profile && profile.name && <span>{profile.name}</span>} {profile && profile.name && <span style={{ cursor: 'pointer' }} onClick={goToProfile}>{profile.name}</span>}
{!profile?.name && ( {!profile?.name && (
<span> <span style={{ cursor: 'pointer' }} onClick={goToProfile}>
{post.pubkey.slice(0, 8) + '...'} {post.pubkey.slice(0, 8) + '...'}
{!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />} {!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />}
</span> </span>
@@ -211,14 +180,20 @@ function FeedCard (props) {
title='Copy to clipboard' title='Copy to clipboard'
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
transform: isClicked ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease', transition: 'transform 0.1s ease',
display: 'inline-block' display: 'inline-block',
marginRight: '5px'
}} }}
onClick={() => copyToClipboard(npub)} onClick={goToProfile}
> >
{getShortNpub(npub)} {getShortNpub(npub)}
</span> </span>
<CopyOnClick
walletProp='npub'
appData={props.appData}
value={npub}
/>
</small> </small>
)} )}
</div> </div>
@@ -4,41 +4,21 @@
// Global npm libraries // Global npm libraries
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Container, Spinner } from 'react-bootstrap' import { Container, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr'
// Local libraries // Local libraries
import config from '../../../../config'
import FeedCard from './feed-card' import FeedCard from './feed-card'
function Following (props) { function Following (props) {
const { appData, posts, profiles } = props const { appData, posts, profiles } = props
const { nostrQueries } = appData
const [followingPosts, setFollowingPosts] = useState([]) const [followingPosts, setFollowingPosts] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
useEffect(() => { useEffect(() => {
const getFollowingPosts = async () => { const getFollowingPosts = async () => {
const followingList = await new Promise((resolve, reject) => { console.log('getFollowingPosts')
let list = [] const { nostrKeyPair } = appData.bchWalletState
const { nostrKeyPair } = appData.bchWalletState const followingList = await nostrQueries.getFollowList(nostrKeyPair.pubHex)
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)
})
})
// Filter posts by following list // Filter posts by following list
let filteredPosts = [] let filteredPosts = []
@@ -55,7 +35,7 @@ function Following (props) {
setLoaded(true) setLoaded(true)
} }
getFollowingPosts() getFollowingPosts()
}, [appData, posts]) }, [appData, posts, nostrQueries])
return ( return (
<Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}> <Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}>
+21 -84
View File
@@ -1,117 +1,54 @@
/* /*
Component for reading the nostr feeds. Component for reading the nostr feeds.
TODO:
- fetchProfile() should retrieve a profile from multiple relays. If the first relay returns a profile,
then that profile can be used and promise resolved. If the first relay returns no profile, the next
one should be tried until all relays are exhausted or one returns a profile.
- useEffect() retrieves the feeds. This should cycle through each relay and posts from each one.
Once each relays has been tried, the posts should remove duplicate entries. Finally posts should
be sorted by date.
- Clicking on a profile picture, name, or npub should open the profile for that user in a new tab.
*/ */
// Global npm libraries // Global npm libraries
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect } from 'react'
import { Container, Nav, Tab, Spinner } from 'react-bootstrap' import { Container, Nav, Tab, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr'
// Local libraries // Local libraries
import Feed from './feed' import Feed from './feed'
import Following from './following' import Following from './following'
import config from '../../../../config'
function Feeds (props) { function Feeds (props) {
const { appData } = props const { appData } = props
const [activeTab, setActiveTab] = useState(appData.lastFeedTab) const [activeTab, setActiveTab] = useState(appData.lastFeedTab)
const { bchWalletState } = appData
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [profiles, setProfiles] = useState({}) const [profiles, setProfiles] = useState({})
// function to fetch profile and set it to profiles state // Load data on component mount.
const fetchProfile = useCallback(async (pubkey) => { useEffect(() => {
// no fetch profile again if it exist const loadData = async () => {
let hasProfileRequest = false // Get feeds
setProfiles(currentProfiles => { const feeds = await appData.nostrQueries.getGlobalFeeds()
if (currentProfiles[pubkey]) { setPosts(feeds)
hasProfileRequest = true setLoaded(true)
return currentProfiles
} else {
const newProfiles = { ...currentProfiles }
newProfiles[pubkey] = { loaded: false }
return newProfiles
}
})
if (hasProfileRequest) return
// const pool = RelayPool(config.nostrRelays) const loadedProfiles = [] // fetched profiles ( this will be used for prevent load the same profile multiple times.)
const pool = RelayPool([config.nostrRelay]) // Map feeds and get feed owner profile.
pool.on('open', relay => { for (let i = 0; i < feeds.length; i++) {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubkey] }) const pubKey = feeds[i].pubkey
})
pool.on('eose', relay => { const exist = loadedProfiles.find((val) => { return val === pubKey })
relay.close() if (exist) { continue }
try { // Fech profile.
// Mark unknown profiles. From this way we can know which profile was fetched and not found. const profile = await appData.nostrQueries.getProfile(pubKey)
setProfiles(currentProfiles => { loadedProfiles.push(pubKey) // mark as loaded
if (!currentProfiles[pubkey]) {
const newProfiles = { ...currentProfiles }
newProfiles[pubkey] = { loaded: true }
return newProfiles
}
return currentProfiles
})
} catch (error) {
console.warn(error)
// skip error
}
})
pool.on('event', (relay, subId, ev) => { // Update profile state
try {
// update profiles data
const profile = JSON.parse(ev.content)
setProfiles(currentProfiles => { setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles } const newProfiles = { ...currentProfiles }
newProfiles[pubkey] = profile newProfiles[pubKey] = profile
return newProfiles 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) { if (!loaded) {
start() loadData()
} }
}, [bchWalletState, loaded, fetchProfile]) }, [loaded, appData])
const onChangeTab = (tab) => { const onChangeTab = (tab) => {
setActiveTab(tab) setActiveTab(tab)
@@ -9,13 +9,12 @@ import Accordion from 'react-bootstrap/Accordion'
import { finalizeEvent } from 'nostr-tools/pure' import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay' import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import { RelayPool } from 'nostr'
// Local libraries // Local libraries
import config from '../../../../config'
function ProfilePost (props) { function ProfilePost (props) {
const { bchWalletState } = props.appData const { appData } = props
const { bchWalletState, writeRelays } = appData
const [accordionKey, setAccordionKey] = useState(null) const [accordionKey, setAccordionKey] = useState(null)
const [onFetch, setOnFetch] = useState(false) const [onFetch, setOnFetch] = useState(false)
const [formLoaded, setFormLoaded] = useState(false) const [formLoaded, setFormLoaded] = useState(false)
@@ -34,40 +33,19 @@ function ProfilePost (props) {
useEffect(() => { useEffect(() => {
// Get Last post from // Get Last post from
const getLastPost = () => { const getLastProfilePost = async () => {
const { nostrKeyPair } = bchWalletState const { nostrKeyPair } = appData.bchWalletState
const lastProfile = await appData.nostrQueries.getProfile(nostrKeyPair.pubHex)
const psf = 'wss://nostr-relay.psfoundation.info' if (lastProfile) {
setFormData(lastProfile)
const pool = RelayPool([psf]) }
pool.on('open', relay => { setFormLoaded(true)
relay.subscribe('subid', { limit: 1, kinds: [0], authors: [nostrKeyPair.pubHex] })
setFormLoaded(true)
})
pool.on('eose', relay => {
console.log('Closing Relay')
setFormLoaded(true)
relay.close()
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
// setPosts(currentPosts => [...currentPosts, ev])
try {
const data = JSON.parse(ev.content)
console.log('data', data)
setFormData(data)
} catch (error) {
}
})
} }
if (!formLoaded) { if (!formLoaded) {
getLastPost() getLastProfilePost()
} }
}, [bchWalletState, formLoaded]) }, [appData, formLoaded])
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value } = e.target const { name, value } = e.target
@@ -111,7 +89,7 @@ function ProfilePost (props) {
console.log('signedEvent: ', signedEvent) console.log('signedEvent: ', signedEvent)
// Publish the post to each relay. // Publish the post to each relay.
config.nostrRelays.map(async (relayUrl) => { writeRelays.map(async (relayUrl) => {
try { try {
// Connect to a relay. // Connect to a relay.
const relay = await Relay.connect(relayUrl) 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 import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
// Local libraries // Local libraries
import config from '../../../../config'
function PublicPost (props) { function PublicPost (props) {
const [accordionKey, setAccordionKey] = useState('0') const [accordionKey, setAccordionKey] = useState('0')
const [onFetch, setOnFetch] = useState(false) const [onFetch, setOnFetch] = useState(false)
const { bchWalletState } = props.appData const { bchWalletState, writeRelays } = props.appData
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
content: '' content: ''
}) })
@@ -64,7 +63,7 @@ function PublicPost (props) {
console.log('signedEvent: ', signedEvent) console.log('signedEvent: ', signedEvent)
// Publish the post to each relay. // Publish the post to each relay.
config.nostrRelays.map(async (relayUrl) => { writeRelays.map(async (relayUrl) => {
try { try {
// Connect to a relay. // Connect to a relay.
const relay = await Relay.connect(relayUrl) const relay = await Relay.connect(relayUrl)
@@ -17,7 +17,7 @@ function Profile (props) {
return ( return (
<> <>
<Container> <Container>
<ProfileRead {...props} setProfile={setProfile} npub={npub} /> <ProfileRead {...props} onProfileRead={setProfile} npub={npub} />
<PublicRead {...props} profile={profile} npub={npub} /> <PublicRead {...props} profile={profile} npub={npub} />
<SlpTokensDisplay {...props} npub={npub} /> <SlpTokensDisplay {...props} npub={npub} />
<NFTForSale {...props} npub={npub} /> <NFTForSale {...props} npub={npub} />
@@ -14,6 +14,7 @@ function NFTForSale (props) {
const [offersAreLoaded, setOffersAreLoaded] = useState(false) const [offersAreLoaded, setOffersAreLoaded] = useState(false)
const [iconsAreLoaded, setIconsAreLoaded] = useState(false) const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [dataAreLoaded, setDataAreLoaded] = useState(false) const [dataAreLoaded, setDataAreLoaded] = useState(false)
const [startAsync, setStartAsync] = useState(false)
const getAddressByNpub = useCallback(async () => { const getAddressByNpub = useCallback(async () => {
const url = `${config.dexServer}/sm/npub/${npub}` const url = `${config.dexServer}/sm/npub/${npub}`
@@ -24,6 +25,7 @@ function NFTForSale (props) {
// Handler for refresh button // Handler for refresh button
const handleRefresh = () => { const handleRefresh = () => {
console.log('handle refresh')
loadNftOffers() loadNftOffers()
} }
@@ -218,9 +220,13 @@ function NFTForSale (props) {
// Effect to load NFTs on component mount // Effect to load NFTs on component mount
useEffect(() => { useEffect(() => {
console.log('loading nfts for sale') // Prevent to load data twice
loadNftOffers() if (!startAsync) {
}, [loadNftOffers]) console.log('loading nfts for sale')
loadNftOffers()
setStartAsync(true)
}
}, [loadNftOffers, startAsync])
// Get Cid from url // Get Cid from url
const parseCid = (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 // Global npm libraries
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Container, Button } from 'react-bootstrap' import { Container, Button } from 'react-bootstrap'
@@ -8,46 +10,31 @@ import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser, faGlobe } from '@fortawesome/free-solid-svg-icons' import { faUser, faGlobe } from '@fortawesome/free-solid-svg-icons'
import * as nip19 from 'nostr-tools/nip19' import * as nip19 from 'nostr-tools/nip19'
import { useNavigate } from 'react-router-dom'
// Local libraries // Local libraries
import config from '../../../../config'
import NostrFormat from '../nostr-format' import NostrFormat from '../nostr-format'
import { RelayPool } from 'nostr'
function ProfileRead (props) { function ProfileRead (props) {
const { npub } = props const { npub, appData } = props
const { setProfile } = props const { onProfileRead } = props
const [post, setPost] = useState({}) const [profile, setProfile] = useState({})
const [pubKey, setPubKey] = useState({})
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [imageError, setImageError] = useState({ picture: false, banner: false }) const [imageError, setImageError] = useState({ picture: false, banner: false })
const navigate = useNavigate()
useEffect(() => { useEffect(() => {
const start = () => { const start = async () => {
const pubHexData = nip19.decode(npub) const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data const pubHex = pubHexData.data
setPubKey(pubHex)
const pool = RelayPool(config.nostrRelays) const profile = await appData.nostrQueries.getProfile(pubHex)
pool.on('open', relay => { if (profile) {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubHex] }) onProfileRead(profile)
setLoaded(true) setProfile(profile)
}) }
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
try {
const profile = JSON.parse(ev.content)
console.log('Profile:', profile)
setPost(profile)
setProfile(profile)
} catch (error) {
console.error('Error parsing profile:', error)
}
})
setLoaded(true) setLoaded(true)
} }
@@ -55,7 +42,7 @@ function ProfileRead (props) {
if (!loaded && npub) { if (!loaded && npub) {
start() start()
} }
}, [loaded, setProfile, npub]) }, [loaded, onProfileRead, npub, appData])
const handleImageError = (type) => { const handleImageError = (type) => {
setImageError(prev => ({ ...prev, [type]: true })) setImageError(prev => ({ ...prev, [type]: true }))
@@ -65,13 +52,20 @@ function ProfileRead (props) {
setImageError(prev => ({ ...prev, [type]: false })) setImageError(prev => ({ ...prev, [type]: false }))
} }
const handleMessage = () => {
console.log('appData', appData)
const { setStartChannelChat } = appData
setStartChannelChat(pubKey)
navigate('/nostr-chat')
}
return ( return (
<Container> <Container>
{/* Banner Section */} {/* Banner Section */}
{post.banner && !imageError.banner && ( {profile.banner && !imageError.banner && (
<div className='position-relative'> <div className='position-relative'>
<img <img
src={post.banner} src={profile.banner}
alt='Profile banner' alt='Profile banner'
className='w-100 rounded-4 shadow-sm' className='w-100 rounded-4 shadow-sm'
style={{ height: '200px', objectFit: 'cover' }} style={{ height: '200px', objectFit: 'cover' }}
@@ -84,10 +78,10 @@ function ProfileRead (props) {
<div className='d-flex flex-column flex-md-row align-items-center gap-4 mb-5 p-3 p-md-5 bg-light rounded-4 shadow-sm'> <div className='d-flex flex-column flex-md-row align-items-center gap-4 mb-5 p-3 p-md-5 bg-light rounded-4 shadow-sm'>
{/* Profile Picture */} {/* Profile Picture */}
<div style={{ width: '120px', height: '120px' }} className='mb-3 mb-md-0'> <div style={{ width: '120px', height: '120px' }} className='mb-3 mb-md-0'>
{post.picture && !imageError.picture {profile.picture && !imageError.picture
? ( ? (
<img <img
src={post.picture} src={profile.picture}
alt='Profile' alt='Profile'
className='rounded-circle shadow w-100 h-100' className='rounded-circle shadow w-100 h-100'
style={{ objectFit: 'cover' }} style={{ objectFit: 'cover' }}
@@ -111,7 +105,7 @@ function ProfileRead (props) {
{/* Profile Information */} {/* Profile Information */}
<div className='flex-grow-1 text-center text-md-start mb-3 mb-md-0 d-flex flex-column align-items-center align-items-md-start'> <div className='flex-grow-1 text-center text-md-start mb-3 mb-md-0 d-flex flex-column align-items-center align-items-md-start'>
<h3 className='mb-2 fw-bold'>{post.name || 'username'}</h3> <h3 className='mb-2 fw-bold'>{profile.name || 'username'}</h3>
<div className='text-muted small mb-3 d-flex align-items-center flex-column flex-md-row'> <div className='text-muted small mb-3 d-flex align-items-center flex-column flex-md-row'>
<span className='text-truncate me-2 mb-2 mb-md-0'> <span className='text-truncate me-2 mb-2 mb-md-0'>
@@ -126,24 +120,24 @@ function ProfileRead (props) {
</div> </div>
{/* About Section */} {/* About Section */}
{post.about && ( {profile.about && (
<div className='fs-6 text-secondary'> <div className='fs-6 text-secondary'>
<NostrFormat content={post.about} /> <NostrFormat content={profile.about} />
</div> </div>
)} )}
{/* Website Link */} {/* Website Link */}
{post.website && ( {profile.website && (
<div className='mb-3 d-flex align-items-center gap-2'> <div className='mb-3 d-flex align-items-center gap-2'>
<FontAwesomeIcon icon={faGlobe} className='text-muted' size='sm' /> <FontAwesomeIcon icon={faGlobe} className='text-muted' size='sm' />
<a <a
href={post.website.startsWith('http') ? post.website : `https://${post.website}`} href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-decoration-none text-muted small' className='text-decoration-none text-muted small'
style={{ wordBreak: 'break-all' }} style={{ wordBreak: 'break-all' }}
> >
{post.website} {profile.website}
</a> </a>
</div> </div>
)} )}
@@ -163,6 +157,8 @@ function ProfileRead (props) {
variant='primary' variant='primary'
className='px-3 px-md-4 py-2 rounded-pill fw-semibold' className='px-3 px-md-4 py-2 rounded-pill fw-semibold'
style={{ minWidth: '120px' }} style={{ minWidth: '120px' }}
onClick={handleMessage}
disabled={!loaded}
> >
<i className='bi bi-chat-dots me-2' /> <i className='bi bi-chat-dots me-2' />
Message Message
@@ -6,49 +6,30 @@ import React, { useEffect, useState } from 'react'
import { Container, Card, Spinner } from 'react-bootstrap' import { Container, Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons' import { faUser } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
// Local libraries // Local libraries
import config from '../../../../config'
import NostrFormat from '../nostr-format' import NostrFormat from '../nostr-format'
function PublicRead (props) { function PublicRead (props) {
const { npub } = props const { npub, appData } = props
const { bchWalletState } = props.appData const { bchWalletState } = appData
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [profilePictureError, setProfilePictureError] = useState(false) const [profilePictureError, setProfilePictureError] = useState(false)
useEffect(() => { useEffect(() => {
// Get Last post from a author // Get Last post from a author
const start = () => { const start = async () => {
const pubHexData = nip19.decode(npub) const pubKeyHex = appData.nostrQueries.npubToHex(npub)
const pubHex = pubHexData.data const feeds = await appData.nostrQueries.getUserFeeds(pubKeyHex)
console.log('pubhex', pubHex) setPosts(feeds)
setLoaded(true)
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])
})
} }
if (!loaded && npub) { if (!loaded && npub) {
start() start()
} }
}, [bchWalletState, loaded, npub]) }, [bchWalletState, loaded, npub, appData])
const handleProfilePictureError = () => { const handleProfilePictureError = () => {
setProfilePictureError(true) setProfilePictureError(true)
+2 -1
View File
@@ -7,6 +7,7 @@ import React, { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { Container, Row, Col, Spinner, Carousel } from 'react-bootstrap' import { Container, Row, Col, Spinner, Carousel } from 'react-bootstrap'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import '../../App.css'
function UserDataReview (props) { function UserDataReview (props) {
const appData = props.appData const appData = props.appData
@@ -108,7 +109,7 @@ function UserDataReview (props) {
{/** Markdown Content */} {/** Markdown Content */}
{markdown {markdown
? ( ? (
<div className='markdown-content my-5 text-center'> <div className='markdown-content my-5'>
<ReactMarkdown>{markdown}</ReactMarkdown> <ReactMarkdown>{markdown}</ReactMarkdown>
</div> </div>
) )
+7
View File
@@ -88,6 +88,13 @@ function NavMenu (props) {
> >
Content Creators Content Creators
</NavLink> </NavLink>
<NavLink
className={currentPath === '/nostr-chat' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-chat'
onClick={handleClickEvent}
>
Nostr Chat
</NavLink>
<hr /> <hr />
+3
View File
@@ -23,6 +23,9 @@ const config = {
'wss://nostr-relay.psfoundation.info', 'wss://nostr-relay.psfoundation.info',
'wss://nos.lol', 'wss://nos.lol',
'wss://relay.damus.io' 'wss://relay.damus.io'
],
chatsId: [
'32b0ebc01c984008977fbe476e064946990df75525331b24b37bbba5e89f4039'
] ]
} }
+65 -11
View File
@@ -1,25 +1,31 @@
import { useState } from 'react' import { useRef, useState } from 'react'
// import { useQueryParam, StringParam } from 'use-query-params' // import { useQueryParam, StringParam } from 'use-query-params'
import useLocalStorageState from 'use-local-storage-state' import useLocalStorageState from 'use-local-storage-state'
import AppUtil from '../util' import AppUtil from '../util'
import NostrQueries from '../services/nostr-queries'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
function useAppState () { function useAppState () {
const location = useLocation() 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 // Load Local storage Data
const [lsState, setLSState, { removeItem }] = useLocalStorageState('bchWalletState-template', { const [lsState, setLSState, { removeItem }] = useLocalStorageState('bchWalletState-template', {
ssr: true, ssr: true,
defaultValue: { defaultValue: localStorageDefault
serverUrl: 'https://free-bch.fullstack.cash' // Default server
},
nftData: {},
lastFeedTab: 'feed'
}) })
console.log('lsState: ', lsState) console.log('lsState: ', lsState)
// Initialize data states // Initialize data states
const [serverUrl, setServerUrl] = useState(lsState.serverUrl) // Default server url const [serverUrl, setServerUrl] = useState(lsState.serverUrl) // Default server url
const [menuState, setMenuState] = useState(0) const [menuState, setMenuState] = useState(0)
@@ -27,7 +33,7 @@ function useAppState () {
const [servers, setServers] = useState([]) const [servers, setServers] = useState([])
const [dexLib, setDexLib] = useState(false) const [dexLib, setDexLib] = useState(false)
const [nostr, setNostr] = useState(false) const [nostr, setNostr] = useState(false)
const [lastFeedTab, setLastFeedTab] = useState(lsState.lastFeedTab || 'feed') const [lastFeedTab, setLastFeedTab] = useState(lsState.lastFeedTab || localStorageDefault.lastFeedTab)
// Startup state management // Startup state management
const [asyncInitStarted, setAsyncInitStarted] = useState(false) const [asyncInitStarted, setAsyncInitStarted] = useState(false)
@@ -44,6 +50,17 @@ function useAppState () {
// NFTs for sale stored data to improve performance // NFTs for sale stored data to improve performance
const [nftForSaleCacheData, setNftForSaleCacheData] = useState(lsState.nftData || {}) 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 }))
// ProfileDM
const [startChannelChat, setStartChannelChat] = useState('')
// The wallet state makes this a true progressive web app (PWA). As // The wallet state makes this a true progressive web app (PWA). As
// balances, UTXOs, and tokens are retrieved, this state is updated. // balances, UTXOs, and tokens are retrieved, this state is updated.
// properties are enumerated here for the purpose of documentation. // properties are enumerated here for the purpose of documentation.
@@ -96,7 +113,7 @@ function useAppState () {
return newBchWalletState return newBchWalletState
}) })
// console.log(`New wallet state: ${JSON.stringify(bchWalletState, null, 2)}`) // console.log(`New wallet state: ${JSON.stringify(bchWalletState, null, 2)}`)
} catch (err) { } catch (err) {
console.error('Error in App.js updateBchWalletState()') console.error('Error in App.js updateBchWalletState()')
throw err throw err
@@ -113,6 +130,35 @@ function useAppState () {
setNftForSaleCacheData(allCacheData) // Update the state setNftForSaleCacheData(allCacheData) // Update the state
updateLocalStorage({ nftData: allCacheData }) // Update the local storage 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 { return {
serverUrl, serverUrl,
@@ -156,7 +202,15 @@ function useAppState () {
lastFeedTab, lastFeedTab,
setLastFeedTab, setLastFeedTab,
isSingleView, isSingleView,
setIsSingleView setIsSingleView,
nostrQueries: nostrQueriesRef.current,
relaysData,
updateRelaysData,
restoreRelaysData,
readRelays,
writeRelays,
startChannelChat,
setStartChannelChat
} }
} }
+509
View File
@@ -0,0 +1,509 @@
/**
* Nostr class for query into relay pools
*
*/
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
import { nip04 } from 'nostr-tools'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import axios from 'axios'
export default class NostrQueries {
constructor ({ relays }) {
this.relays = relays || []
this.loadedProfiles = {}
this.loadedChannelsInfo = {}
this.blackList = []
this.blackListFetched = false
}
async start () {
try {
await this.getBlackList()
} catch (error) {
console.error('NostrQueries.start() error : ', error.message)
throw error
}
}
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
}
const existingProfile = this.loadedProfiles[pubHex]
if (existingProfile) {
console.log(`Returning profile from cache : ${existingProfile.name}`)
return existingProfile
}
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) {
this.loadedProfiles[pubHex] = profile // Store 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
}
const existingChInfo = this.loadedChannelsInfo[channelId]
if (existingChInfo) {
console.log(`Returning ch info from cache : ${existingChInfo.name}`)
return existingChInfo
}
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) {
this.loadedChannelsInfo[channelId] = info
return info
}
}
} catch (error) {
console.warn(error)
}
}
// Get associated pub keys from kind 04 inbox
async getDms (pubKey) {
try {
if (this.relays.length === 0) {
return []
}
let dms = 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: 100, kinds: [4], '#p': [pubKey] }, // received messages
{ limit: 100, kinds: [4], authors: [pubKey] } // sent messages
])
})
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)
if (ev.pubkey === pubKey) {
const pk = ev.tags[0][1]
list = [...list, pk]
} else {
list = [...list, ev.pubkey]
}
})
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
dms = dms.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value === val)
return existingIndex === i
})
return dms
} catch (error) {
console.warn(error)
}
}
async encryptMsg (inObj = {}) {
try {
const { senderPrivKey, receiverPubKey, message } = inObj
const privateKeyBin = hexToBytes(senderPrivKey)
const encryptedMsg = await nip04.encrypt(privateKeyBin, receiverPubKey, message)
console.log('encryptedMsg', encryptedMsg)
return encryptedMsg
} catch (error) {
console.warn(error)
throw error
}
}
async decryptMsg (inObj = {}) {
try {
console.log(inObj)
const { receiverPrivKey, senderPubKey, encryptedMsg } = inObj
const privateKeyBin = hexToBytes(receiverPrivKey)
const decryptedMsg = await nip04.decrypt(privateKeyBin, senderPubKey, encryptedMsg)
console.log('decryptedMsg', decryptedMsg)
return decryptedMsg
} catch (error) {
console.warn(error)
throw error
}
}
async getBlackList () {
try {
if (this.blackListFetched) return
const opts = {
method: 'GET',
url: 'https://blacklists.psfoundation.info/nostr-chat-blacklist.json',
headers: {
'Content-Type': 'application/json'
}
}
const result = await axios.request(opts)
const { data } = result
const { npubs } = data
// // Dev test mock npub for testing purposes
// const myNpubMock = 'npub1y3xq402pu9aqms3khetnt5gzm54t63pzcla56wwfmwshde0ws77qkff5gc'
// npubs.push(myNpubMock)
// //
this.blackListFetched = true
for (let i = 0; i < npubs.length; i++) {
const npub = npubs[i]
const pubKey = this.npubToHex(npub)
this.blackList.push(pubKey)
}
console.log('BlackList ', this.blackList)
return data
} catch (error) {
console.log('Error on getBlackList()')
throw error
}
}
}