fix(Nostr DM): Trying to fix reliability issues with Nostr DMs

This commit is contained in:
Chris Troutner
2026-07-26 14:27:53 -07:00
parent b877d69a3d
commit 5cebf2cfae
6 changed files with 344 additions and 190 deletions
+5 -1
View File
@@ -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
+76
View File
@@ -0,0 +1,76 @@
# Reliable Nostr DMs (Frontend)
## Problem
On the nostr-chat page, the DM sidebar could list conversations (kind-4 history existed on relays), but opening a conversation often showed **no messages**. Sending a DM also often failed to appear in the UI until a full refresh — and sometimes not even then.
Logs from the REST2NOSTR proxy showed:
- `GET /req/dms-…` returning multiple kind-4 events (inbox listing worked)
- `POST /req/dm-{64-char-pubkey}-…` SSE subscriptions finishing immediately after relay `NOTICE` / close (conversation load failed)
The channel-info URL (`kinds:[41]`) is **not** the DM fetch path; it only loads NIP-28 group channel metadata.
## Root cause
[NIP-01](https://nips.nostr.com/1) requires subscription IDs to be at most **64 characters**.
The chat page built SSE subscription IDs like:
```text
dm-{64-char-peer-pubkey}-{timestamp}-{random}
```
That alone is already ~90 characters. The same pattern was used for group chat (`group-{channelId}-…`). Relays reject overlong `REQ` subscription IDs, so the SSE stream ended with no events.
Other Nostr pages (`profile`, `user-feeds`, `global-feeds`, likes, follow list) already used short prefixes and were unaffected.
Additionally:
- Conversation history depended entirely on SSE `onEvent` (no GET bootstrap).
- `selectedChannelIsDm` was inferred from `profiles[ch]`, so opening a DM before the profile finished loading could skip the DM subscription path.
- Outbound messages were not shown until an SSE echo arrived.
## Changes
### Short opaque subscription IDs
[`src/services/nostr-rest-client.js`](../src/services/nostr-rest-client.js) — `generateSubId(prefix)`:
- Accepts a short semantic prefix (`dm`, `group`, `dm-notify`, `profile`, …).
- Produces `{prefix}-{timestampBase36}-{random}` capped under a client-safe length.
- Strips accidental embedded 64-char hex (pubkey / event id) if a caller still embeds one.
Chat call sites now use `generateSubId('dm')` and `generateSubId('group')` instead of embedding hex IDs.
### GET history + live-only SSE
[`src/services/nostr-queries.js`](../src/services/nostr-queries.js):
- `getDmMessages(myPub, peerPub)` — kind-4 conversation history via GET `/req`
- `getChannelMessages(channelId)` — kind-42 group history via GET `/req`
[`src/components/app-body/nostr-chat/index.js`](../src/components/app-body/nostr-chat/index.js):
1. On channel select, load history with those helpers.
2. Decrypt / render, then set `loadedMessages`.
3. Open SSE with `limit: 0` (and `since` when history exists) for live updates only.
### Chat reliability polish
- DM vs group detection uses known DM / group channel membership (not only loaded profiles).
- After a successful publish, the outbound message is shown immediately via `onMsgRead` (optimistic UI).
- Subscription cleanup uses a single `subscription.close()` path (avoids double DELETE / abort noise).
## Side effects
| Area | Impact |
|---|---|
| Feeds / profile / likes / follow | Unchanged (already short sub-ids; still GET queries) |
| Publish (`/event`) | Unchanged |
| Group chat | Same reliability/efficiency pattern as DMs |
| REST contract | Unchanged paths and response shapes |
## Related
- REST2NOSTR companion doc: `nostr/REST2NOSTR/dev-docs/reliable-subscription-ids.md`
+154 -173
View File
@@ -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) {
+50
View File
@@ -279,6 +279,56 @@ export default class NostrQueries {
} }
} }
/**
* Load DM conversation history between myPub and peerPub (kind 4).
* Prefer this GET query over SSE for initial history.
*/
async getDmMessages (myPub, peerPub, limit = 50) {
try {
const subId = generateSubId('dm-hist')
const filters = [
{ limit, kinds: [4], '#p': [myPub], authors: [peerPub] },
{ limit, kinds: [4], '#p': [peerPub], authors: [myPub] }
]
let events = await this.restClient.queryEvents(subId, filters)
events = events.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
events.sort((a, b) => a.created_at - b.created_at)
return events || []
} catch (error) {
console.warn(`Error fetching DM messages for ${peerPub}:`, error)
return []
}
}
/**
* Load group channel message history (kind 42).
*/
async getChannelMessages (channelId, limit = 50) {
try {
const subId = generateSubId('ch-hist')
const filter = { limit, kinds: [42], '#e': [channelId] }
let events = await this.restClient.queryEvents(subId, filter)
events = events.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
events.sort((a, b) => a.created_at - b.created_at)
return events || []
} catch (error) {
console.warn(`Error fetching channel messages for ${channelId}:`, error)
return []
}
}
async encryptMsg (inObj = {}) { async encryptMsg (inObj = {}) {
try { try {
const { senderPrivKey, receiverPubKey, message } = inObj const { senderPrivKey, receiverPubKey, message } = inObj
+33 -5
View File
@@ -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)
} }
} }