Compare commits

...
27 Commits
Author SHA1 Message Date
Chris Troutner 11c14aec3d Merge pull request #88 from Permissionless-Software-Foundation/ct-unstable
fix(Nostr DM): Trying to fix reliability  issues with Nostr DMs
2026-07-26 14:30:50 -07:00
Chris Troutner 5cebf2cfae fix(Nostr DM): Trying to fix reliability issues with Nostr DMs 2026-07-26 14:27:53 -07:00
Chris Troutner b877d69a3d Merge pull request #87 from Permissionless-Software-Foundation/ct-unstable
Ct unstable
2026-07-26 14:03:41 -07:00
Chris Troutner 1df4fb17b7 fixing merge conflict 2026-07-26 14:02:59 -07:00
Chris Troutner 43ef522ede Merge pull request #86 from Permissionless-Software-Foundation/devops
Changing domains to fullstackcash.net
2026-07-26 14:01:17 -07:00
devops-pi 20126e5054 Update Nostr relay URLs to fullstackcash.net 2026-07-13 17:26:24 -07:00
devops-pi 0a87144be9 Fix DEX API URL: fullstack.cash -> fullstackcash.net 2026-07-13 10:50:41 -07:00
devops-pi 375363d5cc Update Nostr relay URL to nostr.fullstackcash.net 2026-07-13 10:35:52 -07:00
Chris Troutner f65306a563 Using env vars for REST2NOSTR API 2026-03-25 09:43:04 -07:00
Chris Troutner 0a33c9a8c7 Merge pull request #85 from Permissionless-Software-Foundation/dh-nft-loading
fix(NFTs): Refine token metadata loading and caching
2025-12-02 08:24:36 -08:00
Daniel Gonzalez 5451d0abe3 fix(NFTs): Refine token metadata loading and caching 2025-12-02 10:36:47 -04:00
Chris Troutner cb72507bea Merge pull request #84 from Permissionless-Software-Foundation/ct-unstable
Counter Offer Cancel Button Functionality
2025-12-01 08:35:34 -08:00
Chris Troutner efb5c69010 fix(env vars): Better env var management 2025-12-01 08:33:09 -08:00
Chris Troutner 81b9c3123a fix(Counter Offers): Adding confirmation modal before canceling Counter Offer 2025-11-29 15:25:07 -08:00
Chris Troutner 69fd424745 feat(Cancel Counter Offer): Adding functionality to Cancel button in Counter Offer page 2025-11-29 12:34:42 -08:00
Chris Troutner f8c14cc1fb Merge pull request #83 from Permissionless-Software-Foundation/ct-unstable
fix(/fungible): Changing /offers to /fungible
2025-11-29 08:25:07 -08:00
Chris Troutner 2e4fd9f876 fix(/fungible): Changing /offers to /fungible 2025-11-29 08:17:00 -08:00
Chris Troutner d31a7f5e73 Merge pull request #82 from Permissionless-Software-Foundation/dh-ctr-offr-page
feat(offers): Created Counter Offers page
2025-11-25 19:01:13 -08:00
Daniel Gonzalez b973f7101a feat(offers): Created Counter Offers page 2025-11-25 20:56:03 -04:00
Chris Troutner 313df177e9 Merge pull request #80 from Permissionless-Software-Foundation/ct-unstable
Dependency Overrides
2025-11-20 05:48:03 -08:00
Chris Troutner 2c34ea5c2e fixing merge conflicts 2025-11-20 05:29:59 -08:00
Chris Troutner aa2d92bfaf Merge pull request #79 from Permissionless-Software-Foundation/dh-counter-offer-data
feat(offer): Added more Counter Offer data
2025-11-20 05:28:08 -08:00
Chris Troutner e9e9982de0 Updated package-lock 2025-11-19 19:10:26 -08:00
Chris Troutner 5a3010ffce fix(deps): Fixing dependency conflicts 2025-11-19 19:05:04 -08:00
Daniel Gonzalez 40467014f0 feat(offer): Added more Counter Offer data 2025-11-19 21:30:41 -04:00
Chris Troutner d94b3fc853 Merge pull request #78 from Permissionless-Software-Foundation/dh-counter-offers-page
feat():Counter Offers Page: Set up test environment
2025-11-17 17:24:31 -08:00
Daniel Gonzalez 51ed5601fa feat():Counter Offers Page: Set up test environment 2025-11-17 18:00:52 -04:00
24 changed files with 11335 additions and 10660 deletions
+5
View File
@@ -0,0 +1,5 @@
# Development environment variables
# These are automatically loaded when running 'npm start'
REACT_APP_DEX_SERVER=http://localhost:5700
REACT_APP_NOSTR_REST_API_URL=http://localhost:5942
+5
View File
@@ -0,0 +1,5 @@
# Production environment variables
# These are automatically loaded when running 'npm run build'
REACT_APP_DEX_SERVER=https://dex-api.fullstackcash.net
REACT_APP_NOSTR_REST_API_URL=https://nostr-relay-api.fullstackcash.net
+5 -1
View File
@@ -1,6 +1,10 @@
# Developer Docs
This file contains notes taken during software development. These notes may eventually be edited into informaiton that goes into the top-level README, or other documentation.
This file contains notes taken during software development. These notes may eventually be edited into information that goes into the top-level README, or other documentation.
## Change notes
- [reliable-nostr-dms.md](./reliable-nostr-dms.md) — Why nostr-chat DMs failed to load, and the frontend changes for short subscription IDs, GET history, and chat reliability.
## Main Features of this App
+76
View File
@@ -0,0 +1,76 @@
# Reliable Nostr DMs (Frontend)
## Problem
On the nostr-chat page, the DM sidebar could list conversations (kind-4 history existed on relays), but opening a conversation often showed **no messages**. Sending a DM also often failed to appear in the UI until a full refresh — and sometimes not even then.
Logs from the REST2NOSTR proxy showed:
- `GET /req/dms-…` returning multiple kind-4 events (inbox listing worked)
- `POST /req/dm-{64-char-pubkey}-…` SSE subscriptions finishing immediately after relay `NOTICE` / close (conversation load failed)
The channel-info URL (`kinds:[41]`) is **not** the DM fetch path; it only loads NIP-28 group channel metadata.
## Root cause
[NIP-01](https://nips.nostr.com/1) requires subscription IDs to be at most **64 characters**.
The chat page built SSE subscription IDs like:
```text
dm-{64-char-peer-pubkey}-{timestamp}-{random}
```
That alone is already ~90 characters. The same pattern was used for group chat (`group-{channelId}-…`). Relays reject overlong `REQ` subscription IDs, so the SSE stream ended with no events.
Other Nostr pages (`profile`, `user-feeds`, `global-feeds`, likes, follow list) already used short prefixes and were unaffected.
Additionally:
- Conversation history depended entirely on SSE `onEvent` (no GET bootstrap).
- `selectedChannelIsDm` was inferred from `profiles[ch]`, so opening a DM before the profile finished loading could skip the DM subscription path.
- Outbound messages were not shown until an SSE echo arrived.
## Changes
### Short opaque subscription IDs
[`src/services/nostr-rest-client.js`](../src/services/nostr-rest-client.js) — `generateSubId(prefix)`:
- Accepts a short semantic prefix (`dm`, `group`, `dm-notify`, `profile`, …).
- Produces `{prefix}-{timestampBase36}-{random}` capped under a client-safe length.
- Strips accidental embedded 64-char hex (pubkey / event id) if a caller still embeds one.
Chat call sites now use `generateSubId('dm')` and `generateSubId('group')` instead of embedding hex IDs.
### GET history + live-only SSE
[`src/services/nostr-queries.js`](../src/services/nostr-queries.js):
- `getDmMessages(myPub, peerPub)` — kind-4 conversation history via GET `/req`
- `getChannelMessages(channelId)` — kind-42 group history via GET `/req`
[`src/components/app-body/nostr-chat/index.js`](../src/components/app-body/nostr-chat/index.js):
1. On channel select, load history with those helpers.
2. Decrypt / render, then set `loadedMessages`.
3. Open SSE with `limit: 0` (and `since` when history exists) for live updates only.
### Chat reliability polish
- DM vs group detection uses known DM / group channel membership (not only loaded profiles).
- After a successful publish, the outbound message is shown immediately via `onMsgRead` (optimistic UI).
- Subscription cleanup uses a single `subscription.close()` path (avoids double DELETE / abort noise).
## Side effects
| Area | Impact |
|---|---|
| Feeds / profile / likes / follow | Unchanged (already short sub-ids; still GET queries) |
| Publish (`/event`) | Unchanged |
| Group chat | Same reliability/efficiency pattern as DMs |
| REST contract | Unchanged paths and response shapes |
## Related
- REST2NOSTR companion doc: `nostr/REST2NOSTR/dev-docs/reliable-subscription-ids.md`
+10094 -10382
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -9,7 +9,7 @@
"@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "1.8.0",
"axios": "0.27.2",
"bch-dex-lib": "2.2.0",
"bch-dex-lib": "2.3.1",
"bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1",
@@ -40,7 +40,8 @@
"eject": "react-app-rewired eject",
"lint": "standard --env mocha --fix",
"pub": "node deploy/publish-main.js",
"pub:ghp": "./deploy/publish-gh-pages.sh"
"pub:ghp": "./deploy/publish-gh-pages.sh",
"postinstall": "rm -rf node_modules/fork-ts-checker-webpack-plugin/node_modules 2>/dev/null || true && (cd node_modules/@eslint/eslintrc && npm install ajv@^6.12.6 --no-save 2>/dev/null || true) && (cd node_modules/eslint && npm install ajv@^6.12.6 --no-save 2>/dev/null || true)"
},
"eslintConfig": {
"extends": "react-app"
@@ -66,6 +67,16 @@
"standard": "17.0.0",
"web3.storage": "4.3.0"
},
"overrides": {
"ajv": "^8.17.1",
"ajv-keywords": "^5.1.0",
"ajv-formats": "^2.1.1",
"babel-loader": {
"schema-utils": "^2.7.1",
"ajv-keywords": "^3.5.2",
"ajv": "^6.12.6"
}
},
"release": {
"publish": [
{
@@ -0,0 +1,184 @@
/*
This component renders as a button. When clicked, it opens a modal that
cancels the counter offer.
This is a functional component with as little state as possible.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Button, Modal, Container, Row, Col, Spinner } from 'react-bootstrap'
import Sweeper from 'bch-token-sweep'
function CancelCounterOfferBtn (props) {
const [show, setShow] = useState(false)
const [statusMsg, setStatusMsg] = useState('')
const [hideSpinner, setHideSpinner] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [showConfirmation, setShowConfirmation] = useState(true) // show the confirmation view
// Update wallet state function
const updateWalletState = async () => {
const wallet = props.appData.wallet
const bchBalance = await wallet.getBalance({ bchAddress: wallet.walletInfo.cashAddress })
await wallet.initialize()
const slpTokens = await wallet.listTokens(wallet.walletInfo.cashAddress)
props.appData.updateBchWalletState({ walletObj: { bchBalance, slpTokens }, appData: props.appData })
}
// Handle cancel/sweep function
const handleCancel = async () => {
try {
console.log('Canceling Counter Offer')
// Hide confirmation and show processing
setShowConfirmation(false)
setHideSpinner(false)
setStatusMsg('')
setIsProcessing(true)
// Get the keypair that holds Counter Offer UTXOs.
// This uses the same derivation path as used in the counter offers page (index 1)
const bchDexLib = props.appData.dexLib
const keyPair = await bchDexLib.take.util.getKeyPair(1)
const wif = keyPair.wif
// Get wallet info for sweeping
const walletWif = props.appData.wallet.walletInfo.privateKey
const toAddr = props.appData.wallet.slpAddress
// Instance the Sweep library and populate UTXOs from network
const sweep = new Sweeper(wif, walletWif, props.appData.wallet)
await sweep.populateObjectFromNetwork()
// Constructing the sweep transaction
const hex = await sweep.sweepTo(toAddr)
const txid = await props.appData.wallet.ar.sendTx(hex)
// Generate success status message
const newStatusMsg = (
<>
<p>Sweep succeeded!</p>
<p>Counter Offer has been canceled.</p>
<p>Transaction ID: {txid}</p>
<p>
<a href={`https://blockchair.com/bitcoin-cash/transaction/${txid}`} target='_blank' rel='noreferrer'>
TX on Blockchair BCH Block Explorer
</a>
</p>
<p>
<a href={`https://token.fullstack.cash/transactions/?txid=${txid}`} target='_blank' rel='noreferrer'>
TX on token explorer
</a>
</p>
</>
)
setHideSpinner(true)
setStatusMsg(newStatusMsg)
setIsProcessing(false)
// Update wallet state to reflect the changes
await updateWalletState()
// Refresh the counter offers list if refreshTokens function is provided
if (props.refreshTokens) {
// Small delay to ensure blockchain state is updated
setTimeout(() => {
props.refreshTokens()
}, 2000)
}
} catch (err) {
console.error('Error in handleCancel(): ', err)
setHideSpinner(true)
setIsProcessing(false)
setStatusMsg(<b style={{ color: 'red' }}>{`Error: ${err.message}`}</b>)
}
}
const handleClose = () => {
// Deny close if the cancel is in progress.
if (isProcessing) {
return
}
setShow(false)
setStatusMsg('')
setHideSpinner(false)
setIsProcessing(false)
setShowConfirmation(true) // Reset confirmation state for next time
}
const handleOpen = () => {
setShow(true)
// Don't start the cancel process immediately - wait for user confirmation
}
return (
<>
<Button variant='danger' onClick={handleOpen} disabled={isProcessing}>Cancel</Button>
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>Cancel Counter Offer</Modal.Title>
</Modal.Header>
<Modal.Body>
{showConfirmation
? (
<Container>
<Row>
<Col style={{ textAlign: 'center' }}>
<p>Are you sure you want to cancel this Counter Offer?</p>
<p style={{ color: '#666', fontSize: '0.9em' }}>
This action will sweep the Counter Offer UTXOs back to your wallet.
</p>
</Col>
</Row>
</Container>
)
: (
<Container>
<Row>
{!hideSpinner && (
<Col style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<span style={{ marginRight: '10px' }}>Canceling Counter Offer...</span>
<Spinner animation='border' />
</Col>
)}
</Row>
<br />
{statusMsg && (
<Row>
<Col style={{ textAlign: 'center' }}>{statusMsg}</Col>
</Row>
)}
</Container>
)}
</Modal.Body>
<Modal.Footer>
{showConfirmation && (
<Row style={{ width: '100%' }}>
<Col xs={12} className='text-center'>
<Button
variant='secondary'
style={{ minWidth: '100px', marginRight: '10px' }}
onClick={handleClose}
>
No, Keep Open
</Button>
<Button
variant='danger'
style={{ minWidth: '100px' }}
onClick={handleCancel}
>
Yes, Cancel It
</Button>
</Col>
</Row>
)}
</Modal.Footer>
</Modal>
</>
)
}
export default CancelCounterOfferBtn
@@ -0,0 +1,90 @@
/*
This Card component summarizes an SLP token.
if a token icon does not exist or cant be loaded , then display a default icon from Jdenticon library.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Card, Button } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
// Local libraries
import InfoButton from './info-button'
import CancelCounterOfferBtn from './cancel-counter-offer-btn'
function CounterOfferCard (props) {
const { token, appData, refreshTokens } = props
const [icon, setIcon] = useState(token.icon)
// Update icon state every token.icon changes
useEffect(() => {
setIcon(token.icon)
}, [token.icon])
return (
<>
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
<Card>
<Card.Body style={{ textAlign: 'center' }}>
{/** If the icon is loaded, display it */
icon && (
<Card.Img
src={icon}
style={{ height: '100px', width: 'auto' }}
onError={(e) => {
setIcon(null) // Set the icon to null if it fails to load the image url.
}}
/>
)
}
{/** If the icon is not loaded, display the Jdenticon */
!icon && (
<Jdenticon size='100' value={token.tokenId} />
)
}
<Card.Title style={{ textAlign: 'center' }}>
<h4>{props.token.ticker}</h4>
</Card.Title>
<Container>
<Row>
<Col>
{props.token.name}
</Col>
</Row>
<br />
<br />
<Row className='text-center'>
<Col xs={4}>
<InfoButton token={props.token} />
</Col>
<Col xs={4}>
<Button
href={`/profile/${props.token.makerNpub}#single-view`}
target='_blank'
rel='noopener noreferrer'
disabled={!props.token.makerNpub}
>
Seller
</Button>
</Col>
<Col xs={4}>
<CancelCounterOfferBtn
token={props.token}
appData={appData}
refreshTokens={refreshTokens}
/>
</Col>
</Row>
</Container>
</Card.Body>
</Card>
</Col>
</>
)
}
export default CounterOfferCard
@@ -0,0 +1,246 @@
/*
This component displays the counter offers for the current wallet.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Spinner, Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import CounterOfferCard from './counter-offer-card'
import AsyncLoad from '../../../services/async-load'
import { faRedo } from '@fortawesome/free-solid-svg-icons'
// Local libraries
const CounterOffers = (props) => {
const { appData } = props
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [dataAreLoaded, setDataAreLoaded] = useState(false)
const [counterOffers, setCounterOffers] = useState([])
const [isLoading, setIsLoading] = useState(true)
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// This function loads the token data .
const lazyLoadTokenData = useCallback(async (tokens) => {
try {
setDataAreLoaded(false)
// map each token and fetch the token data
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// data does not need to be downloaded, so continue with the next one
if (thisToken.dataAlreadyDownloaded) continue
// Try to get token data.
const tokenData = await appData.wallet.getTokenData(thisToken.tokenId)
console.log('tokenData', tokenData)
if (tokenData) {
// Set data to the token object , this can be used to display the token name in the token card component.
thisToken.tokenData = tokenData
thisToken.tokenId = tokenData.genesisData.tokenId
thisToken.ticker = tokenData.genesisData.ticker
thisToken.name = tokenData.genesisData.name
thisToken.decimals = tokenData.genesisData.decimals
thisToken.tokenType = tokenData.genesisData.type
thisToken.url = tokenData.genesisData.documentUri
}
// Mark token to prevent fetch token data again.
thisToken.dataAlreadyDownloaded = true
}
setDataAreLoaded(true)
} catch (error) {
setDataAreLoaded(true)
}
}, [appData])
// Fetch mutable data if it exist and get the token icon url
const fetchTokenMutableData = useCallback(async (token) => {
try {
// Get the token data
const tokenData = token.tokenData
if (!tokenData.mutableData) return false // Return false if no mutable data
// Get the token icon from the mutable data
const cid = parseCid(tokenData.mutableData)
console.log('mutable data cid', cid)
const { json } = await appData.wallet.cid2json({ cid })
console.log('json: ', json)
if (!json) return false
let iconUrl = json.tokenIcon
if (json.fullSizedUrl && json.fullSizedUrl.includes('http')) {
iconUrl = json.fullSizedUrl
}
const userData = json.userData
// Return icon url
return { iconUrl, userData }
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadMutableData = useCallback(async (tokens) => {
try {
setIconsAreLoaded(false)
// map each token and fetch the icon url
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// Incon does not need to be downloaded, so continue with the next one
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const { iconUrl, userData } = await fetchTokenMutableData(thisToken)
console.log('iconUrl', iconUrl)
if (iconUrl) {
// Set the icon url to the token , this can be used to display the icon in the token card component.
thisToken.icon = iconUrl
thisToken.userData = userData
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
}
setIconsAreLoaded(true)
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenMutableData])
// Load the counter offers for the current wallet.
const loadData = useCallback(async () => {
try {
setIsLoading(true)
setCounterOffers([])
// Get the wallet state and server url from the app data.
const { bchWalletState, serverUrl } = appData
// Create a new AsyncLoad instance.
const asyncLoad = new AsyncLoad()
// Load the wallet library.
await asyncLoad.loadWalletLib()
// Get the counter offer derivated wallet
const counterOfferWallet = await asyncLoad.getDerivatedWallet(serverUrl, bchWalletState.mnemonic, "m/44'/245'/0'/0/1")
// Get the utxos from the counter offer wallet.
const utxos = await counterOfferWallet.getUtxos()
const bchUtxos = utxos.bchUtxos
// Get the counter offers data for the current wallet address.
const { counterOffers } = await asyncLoad.getCounterOffersByAddress(bchWalletState.cashAddress)
// Filter the counter offers to only include the ones that are in the utxos.
const filteredCounterOffers = counterOffers.filter(val =>
bchUtxos.some(utxo => utxo.txid === val.counterOfferUtxo)
)
setCounterOffers(filteredCounterOffers)
setIsLoading(false)
// Load the token data for the counter offers in background.
await lazyLoadTokenData(filteredCounterOffers)
// Load the token icons for the counter offers in background.
await lazyLoadMutableData(filteredCounterOffers)
} catch (error) {
setIsLoading(false)
console.error('Error loading counter offers:', error)
}
}, [appData, lazyLoadTokenData, lazyLoadMutableData])
// Start to load the token icons when the component is mounted
useEffect(() => {
loadData()
}, [loadData])
// Handler for refresh button
const handleRefresh = useCallback(() => {
loadData()
}, [loadData])
// Generate the token cards for each token in the wallet.
const generateCards = () => {
return counterOffers.map(thisCounterOffer => (
<CounterOfferCard
appData={appData}
token={thisCounterOffer}
key={`${thisCounterOffer.id}`}
refreshTokens={loadData}
/>
))
}
return (
<>
<Container>
<Row>
<Col xs={6} style={{ textAlign: 'start' }}>
{!isLoading && (
<Row>
<Col xs={6}>
<Button variant='success' onClick={handleRefresh}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
</Col>
</Row>
)}
</Col>
</Row>
<Row>
{appData.asyncInitSucceeded && (
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{/** Show spinner info if tokens are loaded but data is not loaded */
isLoading && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Counter Offers </span>
<Spinner animation='border' />
</div>
)
}
{/** Show spinner info if tokens are loaded but data is not loaded */
!isLoading && !dataAreLoaded && counterOffers.length > 0 && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Data </span>
<Spinner animation='border' />
</div>
)
}
{/** Show spinner info if tokens are loaded but icons are not loaded */
!isLoading && dataAreLoaded && !iconsAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Icons </span>
<Spinner animation='border' />
</div>
)
}
</Col>
)}
</Row>
<br />
<Row>
{generateCards()}
</Row>
{/** Display a message if no tokens are found */}
{!isLoading && counterOffers.length === 0 && (
<Row className='text-center'>
<span> No tokens found in wallet </span>
</Row>
)}
</Container>
</>
)
}
export default CounterOffers
@@ -0,0 +1,146 @@
/*
This component renders as a button. When clicked, it opens a modal that
displays information about the token.
This is a functional component with as little state as possible.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Button, Modal, Container, Row, Col, Spinner } from 'react-bootstrap'
// Takes a string as input. If it matches a pattern for a link, a JSX object is
// returned with a link. Otherwise the original string is returned.
function linkIfUrl (url) {
// Convert the URL into a link if it contains 'http'
if (url?.includes('http')) {
url = (<a href={url} target='_blank' rel='noreferrer'>{url}</a>)
//
} else if (url?.includes('ipfs://')) {
// Convert to a Filecoin link if its an IPFS reference.
const cid = url.substring(7)
url = (<a href={`https://${cid}.ipfs.dweb.link/data.json`} target='_blank' rel='noreferrer'>{url}</a>)
}
return url
}
function InfoButton (props) {
const [show, setShow] = useState(false)
const [mutableDataCid, setMutableDataCid] = useState(null)
const handleClose = () => {
setShow(false)
// props.instance.setState({ showModal: false })
}
const handleOpen = () => {
setShow(true)
}
// Convert the url property of the token to a link, if it matches common patterns.
let url = props.token.url
url = linkIfUrl(props.token.url)
// console.log('props.token: ', props.token)
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// Get token user data if it exists and verify if it contains media or markdown
useEffect(() => {
try {
console.log('props token', props.token)
const userDataStr = props.token.userData
if (userDataStr) {
const userData = JSON.parse(userDataStr)
// If user data contains media or markdown, set the mutable data cid
if (userData?.media || userData?.markdown) {
setMutableDataCid(parseCid(props.token.tokenData.mutableData))
}
}
} catch (error) {
// Do nothing
}
}, [props.token, show])
return (
<>
<Button variant='info' onClick={handleOpen}>Info</Button>
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>Token Information</Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
<Row>
<Col xs={4}><b>Ticker</b>:</Col>
<Col xs={8}>{props.token.ticker}</Col>
</Row>
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Name</b>:</Col>
<Col xs={8}>{props.token.name}</Col>
</Row>
<Row>
<Col xs={4}><b>Token ID</b>:</Col>
<Col xs={8} style={{ wordBreak: 'break-all' }}>
<a href={`https://token.fullstack.cash/?tokenid=${props.token.tokenId}`} target='_blank' rel='noreferrer'>
{props.token.tokenId}
</a>
</Col>
</Row>
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Decimals</b>:</Col>
<Col xs={8}>{props.token.decimals}</Col>
</Row>
<Row>
<Col xs={4}><b>Token Type</b>:</Col>
<Col xs={8}>{props.token.tokenType}</Col>
</Row>
<Row style={{ backgroundColor: '#eee', wordBreak: 'break-all' }}>
<Col xs={4}><b>URL</b>:</Col>
<Col xs={8}>{url}</Col>
</Row>
{!props.token.iconAlreadyDownloaded && (
<div className='text-center'>
<Spinner animation='border' size='sm' />
</div>
)}
{mutableDataCid && (
<Row style={{ paddingTop: '10px' }}>
<Col xs={4}><b>User Data</b>:</Col>
<Col xs={8}>
<Button
href={`/user-data/${props.token.tokenId}#single-view`}
target='_blank'
rel='noopener noreferrer'
variant='success'
>
View User Data
</Button>
</Col>
</Row>
)}
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
</>
)
}
export default InfoButton
@@ -8,7 +8,7 @@ import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Table, Button, Spinner } from 'react-bootstrap'
import axios from 'axios'
import { DatatableWrapper, TableBody, TableHeader } from 'react-bs-datatable'
import AsyncLoad from '../../../services/async-load'
// Local libraries
import config from '../../../config'
import WaitingModal from '../../waiting-modal'
@@ -48,7 +48,7 @@ const TABLE_HEADERS = [
}
]
function Offers (props) {
function Fungible (props) {
const [appData] = useState(props.appData)
const [offers, setOffers] = useState([])
@@ -100,10 +100,19 @@ function Offers (props) {
// Generate a counter offer.
const bchDexLib = appData.dexLib
const { offerData, partialHex } = await bchDexLib.take.takeOffer(
const { offerData, partialHex, counterOfferUtxo } = await bchDexLib.take.takeOffer(
targetOfferEventId
)
// Get counter offer data
const asyncLoad = new AsyncLoad()
const { takerAddr, takerNpub, counterOfferAddr } = await asyncLoad.getCounterOfferMetadata(appData)
offerData.takerAddr = takerAddr
offerData.takerNpub = takerNpub
offerData.counterOfferAddr = counterOfferAddr
offerData.counterOfferUtxo = counterOfferUtxo.txid
// Upload the counter offer to Nostr.
const nostr = appData.nostr
const { eventId, noteId } = await nostr.testNostrUpload({
@@ -228,4 +237,4 @@ function Offers (props) {
)
}
export default Offers
export default Fungible
+4 -2
View File
@@ -28,8 +28,9 @@ import Profile from './nostr/profile/index.js'
import Feeds from './nostr/feeds/index.js'
import ContentCreators from './nostr/content-creators/index.js'
import UserDataReview from './user-data-review'
import Offers from './offers'
import Fungible from './fungible'
import NostrChat from './nostr-chat'
import CounterOffers from './counter-offers'
function AppBody (props) {
// Dependency injection through props
const appData = props.appData
@@ -54,7 +55,8 @@ function AppBody (props) {
<Route path='/feeds' element={<Feeds appData={appData} />} />
<Route path='/content-creators' element={<ContentCreators appData={appData} />} />
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
<Route path='/offers' element={<Offers appData={appData} />} />
<Route path='/fungible' element={<Fungible appData={appData} />} />
<Route path='/counter-offers' element={<CounterOffers appData={appData} />} />
<Route path='/nostr-chat' element={<NostrChat appData={appData} />} />
</Routes>
{/** Show in all paths except the servers view */}
@@ -6,10 +6,10 @@
// Global npm libraries
import React, { useState } from 'react'
import { Button, Modal, Container, Row, Col, Spinner } from 'react-bootstrap'
import AsyncLoad from '../../../services/async-load'
function BuyButton (props) {
const { token, appData, onSuccess } = props
console.log('props: ', props)
const [show, setShow] = useState(false) // show the modal
const [onFetch, setOnFetch] = useState(false) // show the spinner
const [error, setError] = useState(false) // show the error message
@@ -45,10 +45,19 @@ function BuyButton (props) {
// Generate a counter offer.
const bchDexLib = appData.dexLib
const { offerData, partialHex } = await bchDexLib.take.takeOffer(
const { offerData, partialHex, counterOfferUtxo } = await bchDexLib.take.takeOffer(
targetOffer
)
// Get counter offer data
const asyncLoad = new AsyncLoad()
const { takerAddr, takerNpub, counterOfferAddr } = await asyncLoad.getCounterOfferMetadata(appData)
offerData.takerAddr = takerAddr
offerData.takerNpub = takerNpub
offerData.counterOfferAddr = counterOfferAddr
offerData.counterOfferUtxo = counterOfferUtxo.txid
progress.push(<p key='progress-msg2'>Uploading counter offer to Nostr...</p>)
setProgressMsg(progress)
+37 -12
View File
@@ -74,7 +74,8 @@ function NftsForSale (props) {
return offer
}
}, [appData])
// Function to process token metadata (iconUrl , userData).
// Function to process token metadata (iconUrl , userData , tokenData).
const processOfferMetadata = useCallback(async (offer) => {
try {
// Token icon
@@ -82,7 +83,15 @@ function NftsForSale (props) {
offer.icon = offer.tokenIconUrl
offer.iconAlreadyDownloaded = true
offer.userData = JSON.parse(offer.userDataStr)
offer.mutableFromBackend = true
offer.mutableDataSource = 'backend'
}
// tokenData
if (offer.tokenData) {
offer.tokenDataFromBackend = true
offer.tokenDataSource = 'backend'
}
return offer
} catch (error) {
return offer
@@ -126,8 +135,10 @@ function NftsForSale (props) {
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// data does not need to be downloaded, so continue with the next one
if (thisToken.dataAlreadyDownloaded) continue
// Skip if already has tokenData (from backend or cache)
if (thisToken.dataAlreadyDownloaded || thisToken.tokenData) {
continue
}
// Try to get token data.
const tokenData = await appData.wallet.getTokenData(thisToken.tokenId)
@@ -135,6 +146,7 @@ function NftsForSale (props) {
if (tokenData) {
// Set data to the token object , this can be used to display the token name in the token card component.
thisToken.tokenData = tokenData
thisToken.tokenDataSource = 'blockchain'
}
// Mark token to prevent fetch token data again.
@@ -195,6 +207,7 @@ function NftsForSale (props) {
// Set the icon url to the token , this can be used to display the icon in the token card component.
thisToken.icon = iconUrl
thisToken.userData = userData
thisToken.mutableDataSource = 'blockchain'
}
// Mark token to prevent fetch token icon again.
@@ -210,15 +223,23 @@ function NftsForSale (props) {
// Check if token data exists in the cache and add it to the tokens object.
const reviewNftCachedData = useCallback(async (offers) => {
const cacheData = appData.nftForSaleCacheData
console.log(`Token data from cache: ${JSON.stringify(cacheData, null, 2)}`)
// console.log(`Token data from cache: ${JSON.stringify(cacheData, null, 2)}`)
// Map all offers
for (let i = 0; i < offers.length; i++) {
const thisToken = offers[i]
// console.log(`Review offers:`,thisToken)
const cacheToken = cacheData[thisToken.tokenId]
// If cache data exists, add it to the token object
if (cacheToken) {
thisToken.tokenData = cacheToken.tokenData
// Only use cache if backend didn't provide the data
if (cacheToken && !thisToken.icon) {
thisToken.icon = cacheToken.tokenIcon
thisToken.mutableDataSource = 'cache'
}
if (cacheToken && !thisToken.tokenData) {
thisToken.tokenData = cacheToken.tokenData
thisToken.tokenDataSource = 'cache'
}
}
}, [appData])
@@ -228,13 +249,17 @@ function NftsForSale (props) {
// Map all offers
for (let i = 0; i < offers.length; i++) {
const thisToken = offers[i]
// save token icon and token data in cache
const newTokenCacheData = {
tokenIcon: thisToken.icon,
tokenData: thisToken.tokenData
}
console.log('newTokenCacheData: ', newTokenCacheData)
// Only cache if we fetched it ourselves (not from backend)
// Backend data is already "cached" server-side, no need to duplicate
if (thisToken.tokenDataFromBackend && thisToken.mutableFromBackend) continue
const newTokenCacheData = {}
if (!thisToken.tokenDataFromBackend) newTokenCacheData.tokenData = thisToken.tokenData
if (!thisToken.mutableFromBackend) newTokenCacheData.tokenIcon = thisToken.icon
appData.updateNFTCachedData(thisToken.tokenId, newTokenCacheData)
}
}, [appData])
@@ -13,6 +13,7 @@ import BuyButton from './buy-button'
import SellerProfile from './seller-profile'
function TokenCard (props) {
const { token, appData, handleRefresh, hideBuyBtn } = props
console.log('token', token)
const [icon, setIcon] = useState(token.icon)
const [tokenData, setTokenData] = useState(token.tokenData)
+154 -173
View File
@@ -12,8 +12,6 @@ import ChatSidebar from './chat-sidebar'
import ChatMain from './chat-main'
import config from '../../../config'
// Global variables and constants
function NostrChat (props) {
const { appData } = props
const { nostrQueries, bchWalletState, startChannelChat } = appData
@@ -38,16 +36,32 @@ function NostrChat (props) {
const profilesRef = useRef({})
const dmChannelsRef = useRef([])
const groupChannelsRef = useRef(config.chatsId)
// True if channel id is a known DM peer (not a configured group channel).
const isDmChannel = useCallback((ch) => {
if (!ch) return false
if (groupChannelsRef.current.includes(ch)) return false
return dmChannelsRef.current.includes(ch) || !!profilesRef.current[ch]
}, [])
// Close one tracked SSE subscription (single cleanup path).
const closeTrackedSubscription = useCallback((subId) => {
const subscriptions = subscriptionsRef.current
if (subscriptions[subId]) {
subscriptions[subId].close()
delete subscriptions[subId]
}
}, [])
// Reset states on change channel
const onChangeChannel = useCallback((ch) => {
if (selectedChannel === ch) return
const profiles = profilesRef.current
setSelectedChannelIsDm(!!profiles[ch])
setSelectedChannelIsDm(isDmChannel(ch))
setMessages([])
setLoadedMessages(false)
setSelectedChannel(ch)
}, [selectedChannel])
}, [selectedChannel, isDmChannel])
// Add a new DM to the list
const addPrivateMessage = useCallback(async (profile) => {
@@ -55,7 +69,8 @@ function NostrChat (props) {
const exist = dmChannelsRef.current.find(val => val === profile.pubKey)
setMessages([])
setLoadedMessages(false)
onChangeChannel(profile.pubKey)
setSelectedChannelIsDm(true)
setSelectedChannel(profile.pubKey)
if (exist) return
setDmChannels(currentChs => {
let newChs = [...currentChs]
@@ -75,7 +90,7 @@ function NostrChat (props) {
} catch (error) {
console.warn(error)
}
}, [onChangeChannel])
}, [])
// Define starter chat
useEffect(() => {
@@ -116,8 +131,6 @@ function NostrChat (props) {
// Handle read messages
const onMsgRead = useCallback(async (ev) => {
try {
// console.log('onMsgRead() msg: ', msg)
// Update messages list
setMessages(current => {
// ignore existing messages
@@ -161,7 +174,7 @@ function NostrChat (props) {
} catch (error) {
console.warn(error)
}
}, [appData, profilesRef])
}, [appData])
const decryptMsg = useCallback(async ({ ev, pubKey }) => {
try {
@@ -185,179 +198,164 @@ function NostrChat (props) {
}
}, [appData, onMsgRead])
// Handle SSE subscription for group channels
// Load group history via GET, then SSE for live messages only
useEffect(() => {
// fetch messages when channel selected and channel metadata are loaded
if (!selectedChannel || !channelsLoaded || selectedChannelIsDm) return
// wait for deleted chats
if (!deletedChats || !Array.isArray(deletedChats)) return
// Create subscription for group channel messages
const subId = generateSubId(`group-${selectedChannel}`)
const filter = { limit: 10, kinds: [42], '#e': [selectedChannel] }
let cancelled = false
const subId = generateSubId('group')
// Track if EOSE has been called
let eoseCalled = false
let eoseTimeoutId = null
const loadGroup = async () => {
try {
const history = await nostrQueries.getChannelMessages(selectedChannel, 50)
if (cancelled) return
const subscription = restClient.current.createSubscription(subId, filter, {
onEvent: (ev) => {
console.log('Group post retrieved from REST API', ev.content)
const onBlackList = nostrQueries.blackList.find((val) => { return val === ev.pubkey })
const isDeleted = deletedChats.find((val) => { return val.eventId === ev.id })
if (!onBlackList && !isDeleted) {
onMsgRead(ev)
for (const ev of history) {
const onBlackList = nostrQueries.blackList.find((val) => val === ev.pubkey)
const isDeleted = deletedChats.find((val) => val.eventId === ev.id)
if (!onBlackList && !isDeleted) {
onMsgRead(ev)
}
}
},
onEose: () => {
eoseCalled = true
if (eoseTimeoutId) {
clearTimeout(eoseTimeoutId)
eoseTimeoutId = null
if (!cancelled) {
setLoadedMessages(true)
}
if (!selectedChannelIsDm) {
// Use setTimeout to ensure state updates from onEvent callbacks are processed
// before setting loadedMessages to true
setTimeout(() => {
setLoadedMessages(true)
}, 100)
if (cancelled) return
const newest = history.reduce((max, ev) => Math.max(max, ev.created_at || 0), 0)
const liveFilter = {
limit: 0,
kinds: [42],
'#e': [selectedChannel],
...(newest > 0 ? { since: newest } : {})
}
},
onClosed: (message) => {
console.log('Group channel subscription closed:', message)
},
onError: (error) => {
console.warn('Group channel subscription error:', error)
const subscription = restClient.current.createSubscription(subId, liveFilter, {
onEvent: (ev) => {
console.log('Group post retrieved from REST API', ev.content)
const onBlackList = nostrQueries.blackList.find((val) => val === ev.pubkey)
const isDeleted = deletedChats.find((val) => val.eventId === ev.id)
if (!onBlackList && !isDeleted) {
onMsgRead(ev)
}
},
onEose: () => {},
onClosed: (message) => {
console.log('Group channel subscription closed:', message)
},
onError: (error) => {
console.warn('Group channel subscription error:', error)
}
})
if (cancelled) {
subscription.close()
return
}
subscriptionsRef.current[subId] = subscription
} catch (error) {
console.warn('Error loading group messages:', error)
if (!cancelled) setLoadedMessages(true)
}
})
}
subscriptionsRef.current[subId] = subscription
// Set up EOSE timeout fallback - if EOSE doesn't arrive within 10 seconds, set loadedMessages anyway
const EOSE_TIMEOUT_MS = 10000 // 10 seconds
eoseTimeoutId = setTimeout(() => {
if (!eoseCalled && !selectedChannelIsDm) {
console.warn(`EOSE timeout reached for subscription ${subId} - setting loadedMessages to true`)
setLoadedMessages(true)
}
}, EOSE_TIMEOUT_MS)
// Capture values for cleanup
const subscriptionsRefValue = subscriptionsRef.current
const restClientValue = restClient.current
loadGroup()
return () => {
// Clear EOSE timeout if it exists
if (eoseTimeoutId) {
clearTimeout(eoseTimeoutId)
}
// Close subscription on component unmount or selected channel changes
cancelled = true
console.log('Close existing subscription for group channel')
if (subscriptionsRefValue[subId]) {
subscriptionsRefValue[subId].close()
delete subscriptionsRefValue[subId]
}
restClientValue.closeSubscription(subId).catch(err => {
// Subscription already closed is not an error - this is expected behavior
const errorMessage = err?.message || err?.toString() || ''
if (!errorMessage.includes('not found') && !errorMessage.includes('already closed')) {
console.warn('Error closing subscription:', err)
}
})
closeTrackedSubscription(subId)
}
}, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, channelsLoaded, deletedChats])
}, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, channelsLoaded, deletedChats, closeTrackedSubscription])
// Handle SSE subscription for dm channels
// Load DM history via GET, then SSE for live messages only
useEffect(() => {
// fetch messages when channel selected and channel metadata are loaded
if (!selectedChannel || !selectedChannelIsDm) return
let cancelled = false
const { nostrKeyPair } = bchWalletState
const dmPubKey = selectedChannel
const subId = generateSubId('dm')
// Create subscription for DM channel messages
const subId = generateSubId(`dm-${dmPubKey}`)
// Use array of filters for multiple conditions
const filters = [
{ limit: 10, kinds: [4], '#p': [nostrKeyPair.pubHex], authors: [dmPubKey] }, // received messages
{ limit: 10, kinds: [4], '#p': [dmPubKey], authors: [nostrKeyPair.pubHex] } // sent messages
]
const loadDm = async () => {
try {
const history = await nostrQueries.getDmMessages(nostrKeyPair.pubHex, dmPubKey, 50)
if (cancelled) return
// Track if EOSE has been called
let eoseCalled = false
let eoseTimeoutId = null
for (const ev of history) {
if (ev.pubkey === nostrKeyPair.pubHex) {
await decryptMsg({ ev, pubKey: dmPubKey })
} else {
await decryptMsg({ ev, pubKey: ev.pubkey })
}
}
const subscription = restClient.current.createSubscription(subId, filters, {
onEvent: (ev) => {
console.log('DM post retrieved from REST API', ev.content)
// decrypt message
if (ev.pubkey === nostrKeyPair.pubHex) {
// Sent messages
decryptMsg({ ev, pubKey: dmPubKey })
} else {
// Received messages
decryptMsg({ ev, pubKey: ev.pubkey })
if (!cancelled) {
setLoadedMessages(true)
}
},
onEose: () => {
eoseCalled = true
if (eoseTimeoutId) {
clearTimeout(eoseTimeoutId)
eoseTimeoutId = null
if (cancelled) return
const newest = history.reduce((max, ev) => Math.max(max, ev.created_at || 0), 0)
const liveFilters = [
{
limit: 0,
kinds: [4],
'#p': [nostrKeyPair.pubHex],
authors: [dmPubKey],
...(newest > 0 ? { since: newest } : {})
},
{
limit: 0,
kinds: [4],
'#p': [dmPubKey],
authors: [nostrKeyPair.pubHex],
...(newest > 0 ? { since: newest } : {})
}
]
const subscription = restClient.current.createSubscription(subId, liveFilters, {
onEvent: (ev) => {
console.log('DM post retrieved from REST API', ev.content)
if (ev.pubkey === nostrKeyPair.pubHex) {
decryptMsg({ ev, pubKey: dmPubKey })
} else {
decryptMsg({ ev, pubKey: ev.pubkey })
}
},
onEose: () => {},
onClosed: (message) => {
console.log('DM channel subscription closed:', message)
},
onError: (error) => {
console.warn('DM channel subscription error:', error)
}
})
if (cancelled) {
subscription.close()
return
}
if (selectedChannelIsDm) {
// Use setTimeout to ensure state updates from onEvent callbacks are processed
// before setting loadedMessages to true
setTimeout(() => {
setLoadedMessages(true)
}, 100)
}
},
onClosed: (message) => {
console.log('DM channel subscription closed:', message)
},
onError: (error) => {
console.warn('DM channel subscription error:', error)
subscriptionsRef.current[subId] = subscription
} catch (error) {
console.warn('Error loading DM messages:', error)
if (!cancelled) setLoadedMessages(true)
}
})
}
subscriptionsRef.current[subId] = subscription
// Set up EOSE timeout fallback - if EOSE doesn't arrive within 10 seconds, set loadedMessages anyway
const EOSE_TIMEOUT_MS = 10000 // 10 seconds
eoseTimeoutId = setTimeout(() => {
if (!eoseCalled && selectedChannelIsDm) {
console.warn(`EOSE timeout reached for subscription ${subId} - setting loadedMessages to true`)
setLoadedMessages(true)
}
}, EOSE_TIMEOUT_MS)
// Capture values for cleanup
const subscriptionsRefValue = subscriptionsRef.current
const restClientValue = restClient.current
loadDm()
return () => {
// Clear EOSE timeout if it exists
if (eoseTimeoutId) {
clearTimeout(eoseTimeoutId)
}
// Close subscription on component unmount or selected channel changes
cancelled = true
console.log('Close existing subscription for private channel')
if (subscriptionsRefValue[subId]) {
subscriptionsRefValue[subId].close()
delete subscriptionsRefValue[subId]
}
restClientValue.closeSubscription(subId).catch(err => {
// Subscription already closed is not an error - this is expected behavior
const errorMessage = err?.message || err?.toString() || ''
if (!errorMessage.includes('not found') && !errorMessage.includes('already closed')) {
console.warn('Error closing subscription:', err)
}
})
closeTrackedSubscription(subId)
}
}, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, bchWalletState, decryptMsg])
}, [selectedChannel, selectedChannelIsDm, nostrQueries, bchWalletState, decryptMsg, closeTrackedSubscription])
const handleIncomingDms = useCallback(async (pubKey) => {
try {
@@ -400,18 +398,15 @@ function NostrChat (props) {
const { bchWalletState } = appData
const { nostrKeyPair } = bchWalletState
// Create subscription for new incoming DM notifications
const subId = generateSubId('dm-notify')
const filter = { limit: 0, kinds: [4], '#p': [nostrKeyPair.pubHex] } // received messages
const filter = { limit: 0, kinds: [4], '#p': [nostrKeyPair.pubHex] }
const subscription = restClient.current.createSubscription(subId, filter, {
onEvent: (ev) => {
console.log('New message received from REST API', ev)
handleIncomingDms(ev.pubkey)
},
onEose: () => {
// EOSE received, subscription is active
},
onEose: () => {},
onClosed: (message) => {
console.log('DM notification subscription closed:', message)
},
@@ -422,26 +417,11 @@ function NostrChat (props) {
subscriptionsRef.current[subId] = subscription
// Capture values for cleanup
const subscriptionsRefValue = subscriptionsRef.current
const restClientValue = restClient.current
return () => {
// Close subscription on component unmount
console.log('Close existing subscription for DM notifications')
if (subscriptionsRefValue[subId]) {
subscriptionsRefValue[subId].close()
delete subscriptionsRefValue[subId]
}
restClientValue.closeSubscription(subId).catch(err => {
// Subscription already closed is not an error - this is expected behavior
const errorMessage = err?.message || err?.toString() || ''
if (!errorMessage.includes('not found') && !errorMessage.includes('already closed')) {
console.warn('Error closing subscription:', err)
}
})
closeTrackedSubscription(subId)
}
}, [handleIncomingDms, appData, nostrQueries, dmListLoaded, channelsLoaded])
}, [handleIncomingDms, appData, dmListLoaded, channelsLoaded, closeTrackedSubscription])
// Load Dm channels
useEffect(() => {
@@ -545,6 +525,7 @@ function NostrChat (props) {
dmListLoaded={dmListLoaded && channelsLoaded}
onChangeChannel={onChangeChannel}
addPrivateMessage={addPrivateMessage}
onMsgRead={onMsgRead}
{...props}
/>
</Col>
@@ -12,7 +12,7 @@ import { hexToBytes } from '@noble/hashes/utils' // already an installed depende
import NostrRestClient from '../../../services/nostr-rest-client.js'
function MessageInput (props) {
const { appData, selectedChannel, profiles } = props
const { appData, selectedChannel, profiles, selectedChannelIsDm, onMsgRead } = props
const { bchWalletState, nostrQueries } = appData
// Initialize REST client for publishing
const restClient = new NostrRestClient()
@@ -24,9 +24,11 @@ function MessageInput (props) {
// Define input type between private or public
useEffect(() => {
const dmTo = profiles[selectedChannel]
setIsDm(!!dmTo)
setDmProfile(dmTo)
}, [selectedChannel, profiles])
// Prefer explicit DM channel flag; fall back to profile presence
const privateChat = selectedChannelIsDm || !!dmTo
setIsDm(privateChat)
setDmProfile(dmTo || (privateChat ? { pubKey: selectedChannel } : false))
}, [selectedChannel, profiles, selectedChannelIsDm])
const handleSubmitPrivate = async (e) => {
e.preventDefault()
@@ -36,13 +38,15 @@ function MessageInput (props) {
console.log('dm To : ', dmProfile)
const { nostrKeyPair } = bchWalletState
const peerPubKey = dmProfile?.pubKey || selectedChannel
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
const plaintext = message
const encryptedMsg = await nostrQueries.encryptMsg({
senderPrivKey: nostrKeyPair.privHex,
receiverPubKey: dmProfile?.pubKey,
message
receiverPubKey: peerPubKey,
message: plaintext
})
console.log('encryptedMsg', encryptedMsg)
@@ -50,7 +54,7 @@ function MessageInput (props) {
const eventTemplate = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', dmProfile.pubKey]],
tags: [['p', peerPubKey]],
content: encryptedMsg
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
@@ -72,6 +76,14 @@ function MessageInput (props) {
throw err
}
// Show the outbound message immediately (do not wait for SSE echo)
if (onMsgRead) {
onMsgRead({
...signedEvent,
content: plaintext
})
}
setMessage('')
setOnFetch(false)
} catch (error) {
@@ -90,16 +102,14 @@ function MessageInput (props) {
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
// const psf = 'wss://nostr-relay.psfoundation.info'
const plaintext = message
// Generate a post.
const eventTemplate = {
kind: 42,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', selectedChannel, 'root']],
content: message
content: plaintext
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
@@ -120,6 +130,11 @@ function MessageInput (props) {
throw err
}
// Show the outbound message immediately
if (onMsgRead) {
onMsgRead(signedEvent)
}
setMessage('')
setOnFetch(false)
} catch (error) {
@@ -40,6 +40,29 @@ function NFTForSale (props) {
}
}, [appData])
// Function to process token metadata (iconUrl , userData , tokenData).
const processOfferMetadata = useCallback(async (offer) => {
try {
// Token icon
if (offer.tokenIconUrl) {
offer.icon = offer.tokenIconUrl
offer.iconAlreadyDownloaded = true
offer.userData = JSON.parse(offer.userDataStr)
offer.mutableFromBackend = true
offer.mutableDataSource = 'backend'
}
// tokenData
if (offer.tokenData) {
offer.tokenDataFromBackend = true
offer.tokenDataSource = 'backend'
}
return offer
} catch (error) {
return offer
}
}, [])
// Fetch offers
const getNftOffers = useCallback(async (page = 0) => {
try {
@@ -57,7 +80,8 @@ function NFTForSale (props) {
for (let i = 0; i < rawOffers.length; i++) {
const offer = rawOffers[i]
const processedOffer = await processTokenData(offer)
processedOffers.push(processedOffer)
const processedOfferMetadata = await processOfferMetadata(processedOffer)
processedOffers.push(processedOfferMetadata)
}
setOffersAreLoaded(true)
@@ -68,7 +92,7 @@ function NFTForSale (props) {
setOffersAreLoaded(true)
throw err
}
}, [processTokenData, profileAddresses])
}, [processTokenData, processOfferMetadata, profileAddresses])
// This function loads the token data .
const lazyLoadTokenData = useCallback(async (tokens) => {
@@ -78,8 +102,10 @@ function NFTForSale (props) {
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// data does not need to be downloaded, so continue with the next one
if (thisToken.dataAlreadyDownloaded) continue
// Skip if already has tokenData (from backend or cache)
if (thisToken.dataAlreadyDownloaded || thisToken.tokenData) {
continue
}
// Try to get token data.
const tokenData = await appData.wallet.getTokenData(thisToken.tokenId)
@@ -87,6 +113,7 @@ function NFTForSale (props) {
if (tokenData) {
// Set data to the token object , this can be used to display the token name in the token card component.
thisToken.tokenData = tokenData
thisToken.tokenDataSource = 'blockchain'
}
// Mark token to prevent fetch token data again.
@@ -146,6 +173,7 @@ function NFTForSale (props) {
// Set the icon url to the token , this can be used to display the icon in the token card component.
thisToken.icon = iconUrl
thisToken.userData = userData
thisToken.mutableDataSource = 'blockchain'
}
// Mark token to prevent fetch token icon again.
@@ -161,15 +189,23 @@ function NFTForSale (props) {
// Check if token data exists in the cache and add it to the tokens object.
const reviewNftCachedData = useCallback(async (offers) => {
const cacheData = appData.nftForSaleCacheData
console.log(`Token data from cache: ${JSON.stringify(cacheData, null, 2)}`)
// console.log(`Token data from cache: ${JSON.stringify(cacheData, null, 2)}`)
// Map all offers
for (let i = 0; i < offers.length; i++) {
const thisToken = offers[i]
// console.log(`Review offers:`,thisToken)
const cacheToken = cacheData[thisToken.tokenId]
// If cache data exists, add it to the token object
if (cacheToken) {
thisToken.tokenData = cacheToken.tokenData
// Only use cache if backend didn't provide the data
if (cacheToken && !thisToken.icon) {
thisToken.icon = cacheToken.tokenIcon
thisToken.mutableDataSource = 'cache'
}
if (cacheToken && !thisToken.tokenData) {
thisToken.tokenData = cacheToken.tokenData
thisToken.tokenDataSource = 'cache'
}
}
}, [appData])
@@ -179,13 +215,17 @@ function NFTForSale (props) {
// Map all offers
for (let i = 0; i < offers.length; i++) {
const thisToken = offers[i]
// save token icon and token data in cache
const newTokenCacheData = {
tokenIcon: thisToken.icon,
tokenData: thisToken.tokenData
}
console.log('newTokenCacheData: ', newTokenCacheData)
// Only cache if we fetched it ourselves (not from backend)
// Backend data is already "cached" server-side, no need to duplicate
if (thisToken.tokenDataFromBackend && thisToken.mutableFromBackend) continue
const newTokenCacheData = {}
if (!thisToken.tokenDataFromBackend) newTokenCacheData.tokenData = thisToken.tokenData
if (!thisToken.mutableFromBackend) newTokenCacheData.tokenIcon = thisToken.icon
appData.updateNFTCachedData(thisToken.tokenId, newTokenCacheData)
}
}, [appData])
-46
View File
@@ -105,30 +105,6 @@ const SweepWif = (props) => {
}
}
const handleCancelCounterOffers = async () => {
try {
console.log('Executing handleCancelCounterOffers()')
// Set modal initial state
// setShowModal(true)
// setHideSpinner(false)
// setStatusMsg('')
// Get the keypair that holds Counter Offer UTXOs.
const bchDexLib = appData.dexLib
const keyPair = await bchDexLib.take.util.getKeyPair(1)
const wif = keyPair.wif
// console.log(`WIF: ${wif}`)
// Sweep the private key holding the Counter Offer UTXOs.
setWifToSweep(wif)
await handleSweep()
} catch (err) {
console.error('Error in handleCancelCounterOffers(): ', err)
}
}
// Modal component
const getModal = () => (
<Modal show={showModal} size='lg' onHide={() => setShowModal(false)}>
@@ -207,28 +183,6 @@ const SweepWif = (props) => {
</Col>
</Row>
<br />
<br />
<hr />
<Row>
<Col>
<p>
When a Buy order is created, the coins (UTXO) to pay for it are
moved to a secondary address. Clicking the button below will
sweep those funds back into this main wallet. This will also
cancel/invalidate all open Counter Offers that you've created.
</p>
</Col>
</Row>
<Row style={{ textAlign: 'center' }}>
<Col>
<Button onClick={handleCancelCounterOffers}>
Sweep DEX Trading Wallet
</Button>
</Col>
</Row>
</Container>
{showModal && getModal()}
</>
+10 -2
View File
@@ -51,13 +51,21 @@ function NavMenu (props) {
</NavLink>
<NavLink
className={currentPath === '/offers' ? 'nav-link-active' : 'nav-link-inactive'}
to='/offers'
className={currentPath === '/fungible' ? 'nav-link-active' : 'nav-link-inactive'}
to='/fungible'
onClick={handleClickEvent}
>
Fungible Tokens
</NavLink>
<NavLink
className={currentPath === '/counter-offers' ? 'nav-link-active' : 'nav-link-inactive'}
to='/counter-offers'
onClick={handleClickEvent}
>
Counter Offers
</NavLink>
<hr />
<NavLink
+7 -5
View File
@@ -14,19 +14,21 @@ const config = {
ghRepo: 'https://github.com/Permissionless-Software-Foundation/bch-dex-taker-v2',
radicleUrl: 'https://app.radicle.network/seeds/maple.radicle.garden/rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy/remotes/hyyycncbn9qzqmobnhjq9rry6t4mbjiadzjoyhaknzxjcz3cxkpfpc',
dexServer: 'https://dex-api.fullstack.cash',
// dexServer: 'https://dex-api.fullstackcash.net',
// dexServer: 'http://localhost:5700',
dexServer: process.env.REACT_APP_DEX_SERVER || 'https://dex-api.fullstackcash.net',
nostrTopic: 'bch-dex-test-topic-02',
// REST API endpoint for Nostr relay interactions (primary interface)
nostrRestApiUrl: 'https://nostr-relay-api.psfoundation.info',
// nostrRestApiUrl: 'http://localhost:5942',
nostrRestApiUrl:
process.env.REACT_APP_NOSTR_REST_API_URL ||
'https://nostr-relay-api.psfoundation.info',
// Legacy relay URLs kept for reference (may be used in tags, but actual connections use REST API)
nostrRelay: 'wss://nostr-relay.psfoundation.info',
nostrRelay: 'wss://nostr.fullstackcash.net',
nostrRelays: [
'wss://nostr-relay.psfoundation.info',
'wss://nostr.fullstackcash.net',
'wss://nos.lol',
'wss://relay.damus.io'
],
+72
View File
@@ -15,6 +15,10 @@ import { bytesToHex } from '@noble/hashes/utils' // already an installed depende
import { getPublicKey } from 'nostr-tools/pure'
import * as nip19 from 'nostr-tools/nip19'
import config from '../config'
const SERVER = `${config.dexServer}/`
class AsyncLoad {
constructor () {
this.BchWallet = false
@@ -333,6 +337,74 @@ class AsyncLoad {
throw error
}
}
async getDerivatedWallet (restURL, mnemonic, hdPath = "m/44'/245'/0'/0/0", initialize = true) {
try {
const options = {
interface: 'consumer-api',
restURL,
noUpdate: true,
hdPath
}
const wallet = new this.BchWallet(mnemonic, options)
// Wait for wallet to initialize.
await wallet.walletInfoPromise
if (initialize) {
await wallet.initialize()
}
return wallet
} catch (error) {
console.error('Error initStarterWallet: ', error)
throw error
}
}
// Get buyer and counter offer data.
async getCounterOfferMetadata (appData) {
try {
const { bchWalletState, serverUrl } = appData
// Start async load lib
const asyncLoad = new AsyncLoad()
await asyncLoad.loadWalletLib()
// Buyer wallet data
const buyerWallet = await asyncLoad.getDerivatedWallet(serverUrl, bchWalletState.mnemonic, "m/44'/245'/0'/0/0", false)
const buyerAddr = buyerWallet.walletInfo.cashAddress
const buyerKeyPair = asyncLoad.nostrKeyPairFromWIF(buyerWallet.walletInfo.privateKey)
const buyerNpub = buyerKeyPair.npub
// Counter offer wallet data
const counterOfferWallet = await asyncLoad.getDerivatedWallet(serverUrl, bchWalletState.mnemonic, "m/44'/245'/0'/0/1", false)
const counterOfferAddr = counterOfferWallet.walletInfo.cashAddress
return {
takerAddr: buyerAddr,
takerNpub: buyerNpub,
counterOfferAddr
}
} catch (error) {
console.error('Error getCounterOfferMetadata()', error)
throw error
}
}
// Get the counter offers for a given address
async getCounterOffersByAddress (addr) {
try {
const options = {
method: 'GET',
url: `${SERVER}offer/list/counter-offer/${addr}`
}
const result = await axios.request(options)
return result.data
} catch (error) {
console.error('Error getting counter offers by address', error)
throw error
}
}
}
function sleep (ms) {
+50
View File
@@ -279,6 +279,56 @@ export default class NostrQueries {
}
}
/**
* Load DM conversation history between myPub and peerPub (kind 4).
* Prefer this GET query over SSE for initial history.
*/
async getDmMessages (myPub, peerPub, limit = 50) {
try {
const subId = generateSubId('dm-hist')
const filters = [
{ limit, kinds: [4], '#p': [myPub], authors: [peerPub] },
{ limit, kinds: [4], '#p': [peerPub], authors: [myPub] }
]
let events = await this.restClient.queryEvents(subId, filters)
events = events.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
events.sort((a, b) => a.created_at - b.created_at)
return events || []
} catch (error) {
console.warn(`Error fetching DM messages for ${peerPub}:`, error)
return []
}
}
/**
* Load group channel message history (kind 42).
*/
async getChannelMessages (channelId, limit = 50) {
try {
const subId = generateSubId('ch-hist')
const filter = { limit, kinds: [42], '#e': [channelId] }
let events = await this.restClient.queryEvents(subId, filter)
events = events.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
events.sort((a, b) => a.created_at - b.created_at)
return events || []
} catch (error) {
console.warn(`Error fetching channel messages for ${channelId}:`, error)
return []
}
}
async encryptMsg (inObj = {}) {
try {
const { senderPrivKey, receiverPubKey, message } = inObj
+33 -5
View File
@@ -5,12 +5,39 @@
import config from '../config/index.js'
/**
* Generate a unique subscription ID
* @param {string} prefix - Prefix for the subscription ID
* NIP-01 max subscription id length. REST2NOSTR may append a short per-relay
* suffix, so keep client ids well under 64.
*/
export const MAX_CLIENT_SUB_ID_LENGTH = 48
/**
* Generate a unique subscription ID that stays within NIP-01 limits.
* Use a short semantic prefix only (e.g. 'dm', 'group', 'profile', 'dm-notify').
* Do not embed pubkeys or channel hex — those exceed the 64-char limit.
*
* @param {string} prefix - Short prefix for the subscription ID
* @returns {string} Unique subscription ID
*/
export function generateSubId (prefix = 'sub') {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
let safePrefix = String(prefix || 'sub')
// If a caller still passes `dm-${pubkey}` / `group-${channelId}`, drop the hex.
const hexMatch = safePrefix.match(/[0-9a-f]{64}/i)
if (hexMatch) {
const cut = safePrefix.indexOf(hexMatch[0])
safePrefix = safePrefix.slice(0, cut).replace(/-+$/, '') || 'sub'
}
safePrefix = safePrefix.slice(0, 20) || 'sub'
const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).slice(2, 8)
let subId = `${safePrefix}-${timestamp}-${random}`
if (subId.length > MAX_CLIENT_SUB_ID_LENGTH) {
subId = subId.slice(0, MAX_CLIENT_SUB_ID_LENGTH)
}
return subId
}
class NostrRestClient {
@@ -177,12 +204,13 @@ class NostrRestClient {
}
}
// Store subscription for cleanup
// Store subscription for cleanup. close() is the single entry point —
// it aborts the stream and DELETEs on the server (do not also call
// closeSubscription from effect cleanup).
const subscription = {
subId,
abortController,
close: () => {
abortController.abort()
this.closeSubscription(subId)
}
}