diff --git a/dev-docs/README.md b/dev-docs/README.md index 020baf7..57b42dd 100644 --- a/dev-docs/README.md +++ b/dev-docs/README.md @@ -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 diff --git a/dev-docs/reliable-nostr-dms.md b/dev-docs/reliable-nostr-dms.md new file mode 100644 index 0000000..07c2504 --- /dev/null +++ b/dev-docs/reliable-nostr-dms.md @@ -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` diff --git a/src/components/app-body/nostr-chat/index.js b/src/components/app-body/nostr-chat/index.js index 6c3f8a8..e3f41c9 100644 --- a/src/components/app-body/nostr-chat/index.js +++ b/src/components/app-body/nostr-chat/index.js @@ -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} /> diff --git a/src/components/app-body/nostr-chat/message-input.js b/src/components/app-body/nostr-chat/message-input.js index 22ca9ae..0d2b29c 100644 --- a/src/components/app-body/nostr-chat/message-input.js +++ b/src/components/app-body/nostr-chat/message-input.js @@ -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) { diff --git a/src/services/nostr-queries.js b/src/services/nostr-queries.js index f7ee414..fda261f 100644 --- a/src/services/nostr-queries.js +++ b/src/services/nostr-queries.js @@ -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 diff --git a/src/services/nostr-rest-client.js b/src/services/nostr-rest-client.js index facf2d3..2b38287 100644 --- a/src/services/nostr-rest-client.js +++ b/src/services/nostr-rest-client.js @@ -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) } }