mirror of
https://github.com/Permissionless-Software-Foundation/bch-dex-taker-v2.git
synced 2026-09-22 09:12:00 -07:00
Compare commits
11
Commits
v1.53.0
...
ct-unstable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c14aec3d | ||
|
|
5cebf2cfae | ||
|
|
b877d69a3d | ||
|
|
1df4fb17b7 | ||
|
|
43ef522ede | ||
|
|
20126e5054 | ||
|
|
0a87144be9 | ||
|
|
375363d5cc | ||
|
|
f65306a563 | ||
|
|
0a33c9a8c7 | ||
|
|
5451d0abe3 |
+1
-1
@@ -2,4 +2,4 @@
|
||||
# These are automatically loaded when running 'npm start'
|
||||
|
||||
REACT_APP_DEX_SERVER=http://localhost:5700
|
||||
#REACT_APP_NOSTR_REST_API_URL=http://localhost:5942
|
||||
REACT_APP_NOSTR_REST_API_URL=http://localhost:5942
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
# Production environment variables
|
||||
# These are automatically loaded when running 'npm run build'
|
||||
|
||||
REACT_APP_DEX_SERVER=https://dex-api.fullstack.cash
|
||||
REACT_APP_NOSTR_REST_API_URL=https://nostr-relay-api.psfoundation.info
|
||||
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
+5821
-1322
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
+7
-6
@@ -14,20 +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.fullstack.cash',
|
||||
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'
|
||||
],
|
||||
|
||||
@@ -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