mirror of
https://github.com/Permissionless-Software-Foundation/bch-dex-taker-v2.git
synced 2026-09-21 16:52:01 -07:00
fix(Nostr DM): Trying to fix reliability issues with Nostr DMs
This commit is contained in:
+5
-1
@@ -1,6 +1,10 @@
|
|||||||
# Developer Docs
|
# 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
|
## 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`
|
||||||
@@ -12,8 +12,6 @@ import ChatSidebar from './chat-sidebar'
|
|||||||
import ChatMain from './chat-main'
|
import ChatMain from './chat-main'
|
||||||
import config from '../../../config'
|
import config from '../../../config'
|
||||||
|
|
||||||
// Global variables and constants
|
|
||||||
|
|
||||||
function NostrChat (props) {
|
function NostrChat (props) {
|
||||||
const { appData } = props
|
const { appData } = props
|
||||||
const { nostrQueries, bchWalletState, startChannelChat } = appData
|
const { nostrQueries, bchWalletState, startChannelChat } = appData
|
||||||
@@ -38,16 +36,32 @@ function NostrChat (props) {
|
|||||||
|
|
||||||
const profilesRef = useRef({})
|
const profilesRef = useRef({})
|
||||||
const dmChannelsRef = 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
|
// Reset states on change channel
|
||||||
const onChangeChannel = useCallback((ch) => {
|
const onChangeChannel = useCallback((ch) => {
|
||||||
if (selectedChannel === ch) return
|
if (selectedChannel === ch) return
|
||||||
const profiles = profilesRef.current
|
setSelectedChannelIsDm(isDmChannel(ch))
|
||||||
setSelectedChannelIsDm(!!profiles[ch])
|
|
||||||
setMessages([])
|
setMessages([])
|
||||||
setLoadedMessages(false)
|
setLoadedMessages(false)
|
||||||
setSelectedChannel(ch)
|
setSelectedChannel(ch)
|
||||||
}, [selectedChannel])
|
}, [selectedChannel, isDmChannel])
|
||||||
|
|
||||||
// Add a new DM to the list
|
// Add a new DM to the list
|
||||||
const addPrivateMessage = useCallback(async (profile) => {
|
const addPrivateMessage = useCallback(async (profile) => {
|
||||||
@@ -55,7 +69,8 @@ function NostrChat (props) {
|
|||||||
const exist = dmChannelsRef.current.find(val => val === profile.pubKey)
|
const exist = dmChannelsRef.current.find(val => val === profile.pubKey)
|
||||||
setMessages([])
|
setMessages([])
|
||||||
setLoadedMessages(false)
|
setLoadedMessages(false)
|
||||||
onChangeChannel(profile.pubKey)
|
setSelectedChannelIsDm(true)
|
||||||
|
setSelectedChannel(profile.pubKey)
|
||||||
if (exist) return
|
if (exist) return
|
||||||
setDmChannels(currentChs => {
|
setDmChannels(currentChs => {
|
||||||
let newChs = [...currentChs]
|
let newChs = [...currentChs]
|
||||||
@@ -75,7 +90,7 @@ function NostrChat (props) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error)
|
console.warn(error)
|
||||||
}
|
}
|
||||||
}, [onChangeChannel])
|
}, [])
|
||||||
|
|
||||||
// Define starter chat
|
// Define starter chat
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,8 +131,6 @@ function NostrChat (props) {
|
|||||||
// Handle read messages
|
// Handle read messages
|
||||||
const onMsgRead = useCallback(async (ev) => {
|
const onMsgRead = useCallback(async (ev) => {
|
||||||
try {
|
try {
|
||||||
// console.log('onMsgRead() msg: ', msg)
|
|
||||||
|
|
||||||
// Update messages list
|
// Update messages list
|
||||||
setMessages(current => {
|
setMessages(current => {
|
||||||
// ignore existing messages
|
// ignore existing messages
|
||||||
@@ -161,7 +174,7 @@ function NostrChat (props) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error)
|
console.warn(error)
|
||||||
}
|
}
|
||||||
}, [appData, profilesRef])
|
}, [appData])
|
||||||
|
|
||||||
const decryptMsg = useCallback(async ({ ev, pubKey }) => {
|
const decryptMsg = useCallback(async ({ ev, pubKey }) => {
|
||||||
try {
|
try {
|
||||||
@@ -185,179 +198,164 @@ function NostrChat (props) {
|
|||||||
}
|
}
|
||||||
}, [appData, onMsgRead])
|
}, [appData, onMsgRead])
|
||||||
|
|
||||||
// Handle SSE subscription for group channels
|
// Load group history via GET, then SSE for live messages only
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// fetch messages when channel selected and channel metadata are loaded
|
|
||||||
if (!selectedChannel || !channelsLoaded || selectedChannelIsDm) return
|
if (!selectedChannel || !channelsLoaded || selectedChannelIsDm) return
|
||||||
|
|
||||||
// wait for deleted chats
|
|
||||||
if (!deletedChats || !Array.isArray(deletedChats)) return
|
if (!deletedChats || !Array.isArray(deletedChats)) return
|
||||||
|
|
||||||
// Create subscription for group channel messages
|
let cancelled = false
|
||||||
const subId = generateSubId(`group-${selectedChannel}`)
|
const subId = generateSubId('group')
|
||||||
const filter = { limit: 10, kinds: [42], '#e': [selectedChannel] }
|
|
||||||
|
|
||||||
// Track if EOSE has been called
|
const loadGroup = async () => {
|
||||||
let eoseCalled = false
|
try {
|
||||||
let eoseTimeoutId = null
|
const history = await nostrQueries.getChannelMessages(selectedChannel, 50)
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
const subscription = restClient.current.createSubscription(subId, filter, {
|
for (const ev of history) {
|
||||||
onEvent: (ev) => {
|
const onBlackList = nostrQueries.blackList.find((val) => val === ev.pubkey)
|
||||||
console.log('Group post retrieved from REST API', ev.content)
|
const isDeleted = deletedChats.find((val) => val.eventId === ev.id)
|
||||||
const onBlackList = nostrQueries.blackList.find((val) => { return val === ev.pubkey })
|
if (!onBlackList && !isDeleted) {
|
||||||
const isDeleted = deletedChats.find((val) => { return val.eventId === ev.id })
|
onMsgRead(ev)
|
||||||
if (!onBlackList && !isDeleted) {
|
}
|
||||||
onMsgRead(ev)
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onEose: () => {
|
if (!cancelled) {
|
||||||
eoseCalled = true
|
setLoadedMessages(true)
|
||||||
if (eoseTimeoutId) {
|
|
||||||
clearTimeout(eoseTimeoutId)
|
|
||||||
eoseTimeoutId = null
|
|
||||||
}
|
}
|
||||||
if (!selectedChannelIsDm) {
|
|
||||||
// Use setTimeout to ensure state updates from onEvent callbacks are processed
|
if (cancelled) return
|
||||||
// before setting loadedMessages to true
|
|
||||||
setTimeout(() => {
|
const newest = history.reduce((max, ev) => Math.max(max, ev.created_at || 0), 0)
|
||||||
setLoadedMessages(true)
|
const liveFilter = {
|
||||||
}, 100)
|
limit: 0,
|
||||||
|
kinds: [42],
|
||||||
|
'#e': [selectedChannel],
|
||||||
|
...(newest > 0 ? { since: newest } : {})
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onClosed: (message) => {
|
const subscription = restClient.current.createSubscription(subId, liveFilter, {
|
||||||
console.log('Group channel subscription closed:', message)
|
onEvent: (ev) => {
|
||||||
},
|
console.log('Group post retrieved from REST API', ev.content)
|
||||||
onError: (error) => {
|
const onBlackList = nostrQueries.blackList.find((val) => val === ev.pubkey)
|
||||||
console.warn('Group channel subscription error:', error)
|
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
|
loadGroup()
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Clear EOSE timeout if it exists
|
cancelled = true
|
||||||
if (eoseTimeoutId) {
|
|
||||||
clearTimeout(eoseTimeoutId)
|
|
||||||
}
|
|
||||||
// Close subscription on component unmount or selected channel changes
|
|
||||||
console.log('Close existing subscription for group channel')
|
console.log('Close existing subscription for group channel')
|
||||||
if (subscriptionsRefValue[subId]) {
|
closeTrackedSubscription(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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
// fetch messages when channel selected and channel metadata are loaded
|
|
||||||
|
|
||||||
if (!selectedChannel || !selectedChannelIsDm) return
|
if (!selectedChannel || !selectedChannelIsDm) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
const { nostrKeyPair } = bchWalletState
|
const { nostrKeyPair } = bchWalletState
|
||||||
const dmPubKey = selectedChannel
|
const dmPubKey = selectedChannel
|
||||||
|
const subId = generateSubId('dm')
|
||||||
|
|
||||||
// Create subscription for DM channel messages
|
const loadDm = async () => {
|
||||||
const subId = generateSubId(`dm-${dmPubKey}`)
|
try {
|
||||||
// Use array of filters for multiple conditions
|
const history = await nostrQueries.getDmMessages(nostrKeyPair.pubHex, dmPubKey, 50)
|
||||||
const filters = [
|
if (cancelled) return
|
||||||
{ limit: 10, kinds: [4], '#p': [nostrKeyPair.pubHex], authors: [dmPubKey] }, // received messages
|
|
||||||
{ limit: 10, kinds: [4], '#p': [dmPubKey], authors: [nostrKeyPair.pubHex] } // sent messages
|
|
||||||
]
|
|
||||||
|
|
||||||
// Track if EOSE has been called
|
for (const ev of history) {
|
||||||
let eoseCalled = false
|
if (ev.pubkey === nostrKeyPair.pubHex) {
|
||||||
let eoseTimeoutId = null
|
await decryptMsg({ ev, pubKey: dmPubKey })
|
||||||
|
} else {
|
||||||
|
await decryptMsg({ ev, pubKey: ev.pubkey })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const subscription = restClient.current.createSubscription(subId, filters, {
|
if (!cancelled) {
|
||||||
onEvent: (ev) => {
|
setLoadedMessages(true)
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onEose: () => {
|
if (cancelled) return
|
||||||
eoseCalled = true
|
|
||||||
if (eoseTimeoutId) {
|
const newest = history.reduce((max, ev) => Math.max(max, ev.created_at || 0), 0)
|
||||||
clearTimeout(eoseTimeoutId)
|
const liveFilters = [
|
||||||
eoseTimeoutId = null
|
{
|
||||||
|
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
|
subscriptionsRef.current[subId] = subscription
|
||||||
// before setting loadedMessages to true
|
} catch (error) {
|
||||||
setTimeout(() => {
|
console.warn('Error loading DM messages:', error)
|
||||||
setLoadedMessages(true)
|
if (!cancelled) 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
|
loadDm()
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Clear EOSE timeout if it exists
|
cancelled = true
|
||||||
if (eoseTimeoutId) {
|
|
||||||
clearTimeout(eoseTimeoutId)
|
|
||||||
}
|
|
||||||
// Close subscription on component unmount or selected channel changes
|
|
||||||
console.log('Close existing subscription for private channel')
|
console.log('Close existing subscription for private channel')
|
||||||
if (subscriptionsRefValue[subId]) {
|
closeTrackedSubscription(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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, bchWalletState, decryptMsg])
|
}, [selectedChannel, selectedChannelIsDm, nostrQueries, bchWalletState, decryptMsg, closeTrackedSubscription])
|
||||||
|
|
||||||
const handleIncomingDms = useCallback(async (pubKey) => {
|
const handleIncomingDms = useCallback(async (pubKey) => {
|
||||||
try {
|
try {
|
||||||
@@ -400,18 +398,15 @@ function NostrChat (props) {
|
|||||||
const { bchWalletState } = appData
|
const { bchWalletState } = appData
|
||||||
const { nostrKeyPair } = bchWalletState
|
const { nostrKeyPair } = bchWalletState
|
||||||
|
|
||||||
// Create subscription for new incoming DM notifications
|
|
||||||
const subId = generateSubId('dm-notify')
|
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, {
|
const subscription = restClient.current.createSubscription(subId, filter, {
|
||||||
onEvent: (ev) => {
|
onEvent: (ev) => {
|
||||||
console.log('New message received from REST API', ev)
|
console.log('New message received from REST API', ev)
|
||||||
handleIncomingDms(ev.pubkey)
|
handleIncomingDms(ev.pubkey)
|
||||||
},
|
},
|
||||||
onEose: () => {
|
onEose: () => {},
|
||||||
// EOSE received, subscription is active
|
|
||||||
},
|
|
||||||
onClosed: (message) => {
|
onClosed: (message) => {
|
||||||
console.log('DM notification subscription closed:', message)
|
console.log('DM notification subscription closed:', message)
|
||||||
},
|
},
|
||||||
@@ -422,26 +417,11 @@ function NostrChat (props) {
|
|||||||
|
|
||||||
subscriptionsRef.current[subId] = subscription
|
subscriptionsRef.current[subId] = subscription
|
||||||
|
|
||||||
// Capture values for cleanup
|
|
||||||
const subscriptionsRefValue = subscriptionsRef.current
|
|
||||||
const restClientValue = restClient.current
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Close subscription on component unmount
|
|
||||||
console.log('Close existing subscription for DM notifications')
|
console.log('Close existing subscription for DM notifications')
|
||||||
if (subscriptionsRefValue[subId]) {
|
closeTrackedSubscription(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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [handleIncomingDms, appData, nostrQueries, dmListLoaded, channelsLoaded])
|
}, [handleIncomingDms, appData, dmListLoaded, channelsLoaded, closeTrackedSubscription])
|
||||||
|
|
||||||
// Load Dm channels
|
// Load Dm channels
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -545,6 +525,7 @@ function NostrChat (props) {
|
|||||||
dmListLoaded={dmListLoaded && channelsLoaded}
|
dmListLoaded={dmListLoaded && channelsLoaded}
|
||||||
onChangeChannel={onChangeChannel}
|
onChangeChannel={onChangeChannel}
|
||||||
addPrivateMessage={addPrivateMessage}
|
addPrivateMessage={addPrivateMessage}
|
||||||
|
onMsgRead={onMsgRead}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { hexToBytes } from '@noble/hashes/utils' // already an installed depende
|
|||||||
import NostrRestClient from '../../../services/nostr-rest-client.js'
|
import NostrRestClient from '../../../services/nostr-rest-client.js'
|
||||||
|
|
||||||
function MessageInput (props) {
|
function MessageInput (props) {
|
||||||
const { appData, selectedChannel, profiles } = props
|
const { appData, selectedChannel, profiles, selectedChannelIsDm, onMsgRead } = props
|
||||||
const { bchWalletState, nostrQueries } = appData
|
const { bchWalletState, nostrQueries } = appData
|
||||||
// Initialize REST client for publishing
|
// Initialize REST client for publishing
|
||||||
const restClient = new NostrRestClient()
|
const restClient = new NostrRestClient()
|
||||||
@@ -24,9 +24,11 @@ function MessageInput (props) {
|
|||||||
// Define input type between private or public
|
// Define input type between private or public
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dmTo = profiles[selectedChannel]
|
const dmTo = profiles[selectedChannel]
|
||||||
setIsDm(!!dmTo)
|
// Prefer explicit DM channel flag; fall back to profile presence
|
||||||
setDmProfile(dmTo)
|
const privateChat = selectedChannelIsDm || !!dmTo
|
||||||
}, [selectedChannel, profiles])
|
setIsDm(privateChat)
|
||||||
|
setDmProfile(dmTo || (privateChat ? { pubKey: selectedChannel } : false))
|
||||||
|
}, [selectedChannel, profiles, selectedChannelIsDm])
|
||||||
|
|
||||||
const handleSubmitPrivate = async (e) => {
|
const handleSubmitPrivate = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -36,13 +38,15 @@ function MessageInput (props) {
|
|||||||
console.log('dm To : ', dmProfile)
|
console.log('dm To : ', dmProfile)
|
||||||
|
|
||||||
const { nostrKeyPair } = bchWalletState
|
const { nostrKeyPair } = bchWalletState
|
||||||
|
const peerPubKey = dmProfile?.pubKey || selectedChannel
|
||||||
// Convert private key to binary
|
// Convert private key to binary
|
||||||
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
|
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
|
||||||
|
const plaintext = message
|
||||||
|
|
||||||
const encryptedMsg = await nostrQueries.encryptMsg({
|
const encryptedMsg = await nostrQueries.encryptMsg({
|
||||||
senderPrivKey: nostrKeyPair.privHex,
|
senderPrivKey: nostrKeyPair.privHex,
|
||||||
receiverPubKey: dmProfile?.pubKey,
|
receiverPubKey: peerPubKey,
|
||||||
message
|
message: plaintext
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('encryptedMsg', encryptedMsg)
|
console.log('encryptedMsg', encryptedMsg)
|
||||||
@@ -50,7 +54,7 @@ function MessageInput (props) {
|
|||||||
const eventTemplate = {
|
const eventTemplate = {
|
||||||
kind: 4,
|
kind: 4,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [['p', dmProfile.pubKey]],
|
tags: [['p', peerPubKey]],
|
||||||
content: encryptedMsg
|
content: encryptedMsg
|
||||||
}
|
}
|
||||||
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
|
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
|
||||||
@@ -72,6 +76,14 @@ function MessageInput (props) {
|
|||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show the outbound message immediately (do not wait for SSE echo)
|
||||||
|
if (onMsgRead) {
|
||||||
|
onMsgRead({
|
||||||
|
...signedEvent,
|
||||||
|
content: plaintext
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
setMessage('')
|
setMessage('')
|
||||||
setOnFetch(false)
|
setOnFetch(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -90,16 +102,14 @@ function MessageInput (props) {
|
|||||||
|
|
||||||
// Convert private key to binary
|
// Convert private key to binary
|
||||||
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
|
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
|
||||||
|
const plaintext = message
|
||||||
// Relay list
|
|
||||||
// const psf = 'wss://nostr-relay.psfoundation.info'
|
|
||||||
|
|
||||||
// Generate a post.
|
// Generate a post.
|
||||||
const eventTemplate = {
|
const eventTemplate = {
|
||||||
kind: 42,
|
kind: 42,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [['e', selectedChannel, 'root']],
|
tags: [['e', selectedChannel, 'root']],
|
||||||
content: message
|
content: plaintext
|
||||||
}
|
}
|
||||||
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
|
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
|
||||||
|
|
||||||
@@ -120,6 +130,11 @@ function MessageInput (props) {
|
|||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show the outbound message immediately
|
||||||
|
if (onMsgRead) {
|
||||||
|
onMsgRead(signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
setMessage('')
|
setMessage('')
|
||||||
setOnFetch(false)
|
setOnFetch(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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 = {}) {
|
async encryptMsg (inObj = {}) {
|
||||||
try {
|
try {
|
||||||
const { senderPrivKey, receiverPubKey, message } = inObj
|
const { senderPrivKey, receiverPubKey, message } = inObj
|
||||||
|
|||||||
@@ -5,12 +5,39 @@
|
|||||||
import config from '../config/index.js'
|
import config from '../config/index.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique subscription ID
|
* NIP-01 max subscription id length. REST2NOSTR may append a short per-relay
|
||||||
* @param {string} prefix - Prefix for the subscription ID
|
* 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
|
* @returns {string} Unique subscription ID
|
||||||
*/
|
*/
|
||||||
export function generateSubId (prefix = 'sub') {
|
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 {
|
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 = {
|
const subscription = {
|
||||||
subId,
|
subId,
|
||||||
abortController,
|
abortController,
|
||||||
close: () => {
|
close: () => {
|
||||||
abortController.abort()
|
|
||||||
this.closeSubscription(subId)
|
this.closeSubscription(subId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user