mirror of
https://github.com/Permissionless-Software-Foundation/bch-dex-taker-v2.git
synced 2026-09-21 16:52:01 -07:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c14aec3d | ||
|
|
5cebf2cfae | ||
|
|
b877d69a3d | ||
|
|
1df4fb17b7 | ||
|
|
43ef522ede | ||
|
|
20126e5054 | ||
|
|
0a87144be9 | ||
|
|
375363d5cc | ||
|
|
f65306a563 | ||
|
|
0a33c9a8c7 | ||
|
|
5451d0abe3 | ||
|
|
cb72507bea | ||
|
|
efb5c69010 | ||
|
|
81b9c3123a | ||
|
|
69fd424745 | ||
|
|
f8c14cc1fb | ||
|
|
2e4fd9f876 | ||
|
|
d31a7f5e73 | ||
|
|
b973f7101a | ||
|
|
313df177e9 | ||
|
|
2c34ea5c2e | ||
|
|
aa2d92bfaf | ||
|
|
e9e9982de0 | ||
|
|
5a3010ffce | ||
|
|
40467014f0 | ||
|
|
d94b3fc853 | ||
|
|
51ed5601fa | ||
|
|
d72d98a2d7 | ||
|
|
7237cb6c6a |
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
Generated
+10094
-10382
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -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
|
||||
+13
-4
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -43,8 +43,10 @@ function SellerProfile (props) {
|
||||
|
||||
// go to profile
|
||||
const goToProfile = () => {
|
||||
const profileUrl = `${window.location.origin}/profile/${npub}#single-view`
|
||||
window.open(profileUrl, '_blank')
|
||||
if (npub) {
|
||||
const profileUrl = `${window.location.origin}/profile/${npub}#single-view`
|
||||
window.open(profileUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// Get display name - use profile name, npub short form, or "Anonymous User"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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()}
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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'
|
||||
],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user