From cffeb76603209ba936dac2102fb62e60fe706dbd Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 5 Nov 2025 07:31:54 -0800 Subject: [PATCH] feat(Nostr): Switching to use of API from websockets --- src/components/app-body/nostr-chat/index.js | 193 +++++--- .../app-body/nostr-chat/message-input.js | 58 +-- .../nostr/content-creators/follow-btn.js | 27 +- .../app-body/nostr/feeds/feed-card.js | 35 +- .../app-body/nostr/nostr-post/profile-post.js | 28 +- .../app-body/nostr/nostr-post/public-post.js | 28 +- src/config/index.js | 7 +- src/hooks/state.js | 16 +- src/services/nostr-queries.js | 450 ++++++------------ src/services/nostr-rest-client.js | 239 ++++++++++ src/services/nostr.js | 22 +- 11 files changed, 590 insertions(+), 513 deletions(-) create mode 100644 src/services/nostr-rest-client.js diff --git a/src/components/app-body/nostr-chat/index.js b/src/components/app-body/nostr-chat/index.js index 83892cc..3a59198 100644 --- a/src/components/app-body/nostr-chat/index.js +++ b/src/components/app-body/nostr-chat/index.js @@ -5,7 +5,7 @@ // Global npm libraries import React, { useCallback, useEffect, useState, useRef } from 'react' import { Container, Row, Col } from 'react-bootstrap' -import { RelayPool } from 'nostr' +import NostrRestClient, { generateSubId } from '../../../services/nostr-rest-client.js' // Local libraries import ChatSidebar from './chat-sidebar' @@ -17,6 +17,10 @@ import config from '../../../config' function NostrChat (props) { const { appData } = props const { nostrQueries, bchWalletState, startChannelChat } = appData + // Initialize REST client for SSE subscriptions + const restClient = useRef(new NostrRestClient()) + // Track active subscriptions for cleanup + const subscriptionsRef = useRef({}) const [messages, setMessages] = useState([]) const [loadedMessages, setLoadedMessages] = useState(false) @@ -181,7 +185,7 @@ function NostrChat (props) { } }, [appData, onMsgRead]) - // Handle nostr pool for group channels + // Handle SSE subscription for group channels useEffect(() => { // fetch messages when channel selected and channel metadata are loaded if (!selectedChannel || !channelsLoaded || selectedChannelIsDm) return @@ -189,40 +193,52 @@ function NostrChat (props) { // wait for deleted chats if (!deletedChats || !Array.isArray(deletedChats)) return - // Load messages for group channel - const relays = nostrQueries.relays - if (relays.length === 0) { - return - } + // Create subscription for group channel messages + const subId = generateSubId(`group-${selectedChannel}`) + const filter = { limit: 10, kinds: [42], '#e': [selectedChannel] } - const pool = RelayPool(relays) - - // const pool = RelayPool([config.nostrRelay]) - pool.on('open', relay => { - relay.subscribe('REQ', { limit: 10, kinds: [42], '#e': [selectedChannel] }) - }) - - pool.on('eose', relay => { - if (!selectedChannelIsDm) { - setLoadedMessages(true) + 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) + } + }, + onEose: () => { + if (!selectedChannelIsDm) { + setLoadedMessages(true) + } + }, + onClosed: (message) => { + console.log('Group channel subscription closed:', message) + }, + onError: (error) => { + console.warn('Group channel subscription error:', error) } }) - pool.on('event', (relay, subId, ev) => { - console.log('Group post retrieved from ', relay.url, 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) - }) + subscriptionsRef.current[subId] = subscription + + // Capture values for cleanup + const subscriptionsRefValue = subscriptionsRef.current + const restClientValue = restClient.current return () => { - // Close pool on component unmount or selected channel changes - console.log('Close existing pool for group channel') - pool.close() + // Close subscription on component unmount or selected channel changes + console.log('Close existing subscription for group channel') + if (subscriptionsRefValue[subId]) { + subscriptionsRefValue[subId].close() + delete subscriptionsRefValue[subId] + } + restClientValue.closeSubscription(subId).catch(err => { + console.warn('Error closing subscription:', err) + }) } }, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, channelsLoaded, deletedChats]) - // Handle nostr pool for dm channels + // Handle SSE subscription for dm channels useEffect(() => { // fetch messages when channel selected and channel metadata are loaded @@ -231,44 +247,55 @@ function NostrChat (props) { const { nostrKeyPair } = bchWalletState const dmPubKey = selectedChannel - // Load messages for group channel - const relays = nostrQueries.relays - if (relays.length === 0) { - return - } + // 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 pool = RelayPool(relays) - - // const pool = RelayPool([config.nostrRelay]) - pool.on('open', relay => { - relay.subscribe('REQ', [ - { limit: 10, kinds: [4], '#p': [nostrKeyPair.pubHex], authors: [dmPubKey] }, // received messages - { limit: 10, kinds: [4], '#p': [dmPubKey], authors: [nostrKeyPair.pubHex] } // sent messages - ]) - }) - - pool.on('eose', relay => { - if (selectedChannelIsDm) { - setLoadedMessages(true) + 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 }) + } + }, + onEose: () => { + if (selectedChannelIsDm) { + setLoadedMessages(true) + } + }, + onClosed: (message) => { + console.log('DM channel subscription closed:', message) + }, + onError: (error) => { + console.warn('DM channel subscription error:', error) } }) - pool.on('event', (relay, subId, ev) => { - console.log('DM post retrieved from ', relay.url, ev.content) - // decrpt message - if (ev.pubkey === nostrKeyPair.pubHex) { - // Sent messages - decryptMsg({ ev, pubKey: dmPubKey }) - } else { - // Received messages - decryptMsg({ ev, pubKey: ev.pubkey }) - } - }) + subscriptionsRef.current[subId] = subscription + + // Capture values for cleanup + const subscriptionsRefValue = subscriptionsRef.current + const restClientValue = restClient.current return () => { - // Close pool on component unmount or selected channel changes - console.log('Close existing pool for private channel') - pool.close() + // Close subscription on component unmount or selected channel changes + console.log('Close existing subscription for private channel') + if (subscriptionsRefValue[subId]) { + subscriptionsRefValue[subId].close() + delete subscriptionsRefValue[subId] + } + restClientValue.closeSubscription(subId).catch(err => { + console.warn('Error closing subscription:', err) + }) } }, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, bchWalletState, decryptMsg]) @@ -307,30 +334,48 @@ function NostrChat (props) { } }, [nostrQueries, dmListLoaded]) - // Keep live NPI04 for new dms + // Keep live subscription for new DMs useEffect(() => { if (!dmListLoaded || !channelsLoaded) return const { bchWalletState } = appData const { nostrKeyPair } = bchWalletState - const relays = nostrQueries.relays - const pool = RelayPool(relays) - // const pool = RelayPool([config.nostrRelay]) - pool.on('open', relay => { - relay.subscribe('REQ', [ - { limit: 0, kinds: [4], '#p': [nostrKeyPair.pubHex] } // received messages - ]) + // Create subscription for new incoming DM notifications + const subId = generateSubId('dm-notify') + const filter = { limit: 0, kinds: [4], '#p': [nostrKeyPair.pubHex] } // received messages + + 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 + }, + onClosed: (message) => { + console.log('DM notification subscription closed:', message) + }, + onError: (error) => { + console.warn('DM notification subscription error:', error) + } }) - pool.on('event', (relay, subId, ev) => { - console.log('New message received', ev) - handleIncomingDms(ev.pubkey) - }) + subscriptionsRef.current[subId] = subscription + + // Capture values for cleanup + const subscriptionsRefValue = subscriptionsRef.current + const restClientValue = restClient.current return () => { - // Close pool on component unmount or selected channel changes - console.log('Close existing pool for private channel') - pool.close() + // 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 => { + console.warn('Error closing subscription:', err) + }) } }, [handleIncomingDms, appData, nostrQueries, dmListLoaded, channelsLoaded]) diff --git a/src/components/app-body/nostr-chat/message-input.js b/src/components/app-body/nostr-chat/message-input.js index e8f19cf..22ca9ae 100644 --- a/src/components/app-body/nostr-chat/message-input.js +++ b/src/components/app-body/nostr-chat/message-input.js @@ -9,11 +9,13 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faPaperPlane } from '@fortawesome/free-solid-svg-icons' import { finalizeEvent } from 'nostr-tools/pure' import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency -import { Relay } from 'nostr-tools/relay' +import NostrRestClient from '../../../services/nostr-rest-client.js' function MessageInput (props) { const { appData, selectedChannel, profiles } = props - const { bchWalletState, writeRelays, nostrQueries } = appData + const { bchWalletState, nostrQueries } = appData + // Initialize REST client for publishing + const restClient = new NostrRestClient() const [isDm, setIsDm] = useState(false) const [dmProfile, setDmProfile] = useState(false) const [message, setMessage] = useState('') @@ -57,25 +59,19 @@ function MessageInput (props) { const signedEvent = finalizeEvent(eventTemplate, privateKeyBin) console.log('signedEvent: ', signedEvent) - // Publish the post to each relay. - for (let i = 0; i < writeRelays.length; i++) { - const relayUrl = writeRelays[i] + // Publish the message via REST API (handles broadcasting to multiple relays) + try { + const result = await restClient.publishEvent(signedEvent) + console.log('result: ', result) - try { - // Connect to a relay. - const relay = await Relay.connect(relayUrl) - console.log(`connected to ${relay.url}`) - - // Publish the message to the relay. - const result = await relay.publish(signedEvent) - console.log('result: ', result) - - // Close the connection to the relay. - relay.close() - } catch (err) { - console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`) + if (!result.accepted) { + throw new Error(`Failed to publish: ${result.message || 'Unknown error'}`) } + } catch (err) { + console.warn(`Error publishing message: ${err}`) + throw err } + setMessage('') setOnFetch(false) } catch (error) { @@ -111,25 +107,19 @@ function MessageInput (props) { const signedEvent = finalizeEvent(eventTemplate, privateKeyBin) console.log('signedEvent: ', signedEvent) - // Publish the post to each relay. - for (let i = 0; i < writeRelays.length; i++) { - const relayUrl = writeRelays[i] + // Publish the message via REST API (handles broadcasting to multiple relays) + try { + const result = await restClient.publishEvent(signedEvent) + console.log('result: ', result) - try { - // Connect to a relay. - const relay = await Relay.connect(relayUrl) - console.log(`connected to ${relay.url}`) - - // Publish the message to the relay. - const result = await relay.publish(signedEvent) - console.log('result: ', result) - - // Close the connection to the relay. - relay.close() - } catch (err) { - console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`) + if (!result.accepted) { + throw new Error(`Failed to publish: ${result.message || 'Unknown error'}`) } + } catch (err) { + console.warn(`Error publishing message: ${err}`) + throw err } + setMessage('') setOnFetch(false) } catch (error) { diff --git a/src/components/app-body/nostr/content-creators/follow-btn.js b/src/components/app-body/nostr/content-creators/follow-btn.js index e9c9afe..da174e7 100644 --- a/src/components/app-body/nostr/content-creators/follow-btn.js +++ b/src/components/app-body/nostr/content-creators/follow-btn.js @@ -1,13 +1,15 @@ import React, { useState, useEffect } from 'react' import { Spinner } from 'react-bootstrap' import { finalizeEvent } from 'nostr-tools/pure' -import { Relay } from 'nostr-tools/relay' import { hexToBytes } from '@noble/hashes/utils' +import NostrRestClient from '../../../../services/nostr-rest-client.js' function FollowBtn (props) { const [onFetch, setOnFetch] = useState(false) const [isFollowing, setIsFollowing] = useState(false) const { creator, appData, creatorProfile, followList, refreshFollowList } = props + // Initialize REST client for publishing + const restClient = new NostrRestClient() useEffect(() => { const isFollowing = followList.find(item => item[1] === creator.pubkey) @@ -27,8 +29,8 @@ function FollowBtn (props) { if (!existing) { const creatorName = creatorProfile.name || '' - // add creator to the list - currentList.push(['p', creator.pubkey, 'wss://nostr-relay.psfoundation.info', creatorName]) + // add creator to the list (relay URL in tag is optional, REST API handles relay selection) + currentList.push(['p', creator.pubkey, '', creatorName]) await submitFollowList(currentList) await refreshFollowList() } else { @@ -73,10 +75,7 @@ function FollowBtn (props) { // Convert private key to binary const privateKeyBin = hexToBytes(nostrKeyPair.privHex) - // Relay list - const psf = 'wss://nostr-relay.psfoundation.info' - - // Generate a post. + // Generate a follow list event. const eventTemplate = { kind: 3, created_at: Math.floor(Date.now() / 1000), @@ -89,16 +88,14 @@ function FollowBtn (props) { const signedEvent = finalizeEvent(eventTemplate, privateKeyBin) console.log('signedEvent: ', signedEvent) - // Connect to a relay. - const relay = await Relay.connect(psf) - console.log(`connected to ${relay.url}`) - - // Publish the message to the relay. - const result = await relay.publish(signedEvent) + // Publish the follow list via REST API (handles broadcasting to multiple relays) + const result = await restClient.publishEvent(signedEvent) console.log('result: ', result) - // Close the connection to the relay. - relay.close() + if (!result.accepted) { + throw new Error(`Failed to publish follow list: ${result.message || 'Unknown error'}`) + } + setTimeout(() => { setOnFetch(false) }, 1000) diff --git a/src/components/app-body/nostr/feeds/feed-card.js b/src/components/app-body/nostr/feeds/feed-card.js index 438d274..de0019c 100644 --- a/src/components/app-body/nostr/feeds/feed-card.js +++ b/src/components/app-body/nostr/feeds/feed-card.js @@ -10,8 +10,8 @@ import { faUser, faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-ico import { faHeart } from '@fortawesome/free-regular-svg-icons' import * as nip19 from 'nostr-tools/nip19' import { finalizeEvent } from 'nostr-tools/pure' -import { Relay } from 'nostr-tools/relay' import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency +import NostrRestClient from '../../../../services/nostr-rest-client.js' // Local libraries import CopyOnClick from '../../bch-wallet/copy-on-click.js' @@ -19,7 +19,9 @@ import NostrFormat from '../nostr-format' function FeedCard (props) { const { post, appData, profiles } = props - const { nostrKeyPair, writeRelays } = appData.bchWalletState + const { nostrKeyPair } = appData.bchWalletState + // Initialize REST client for publishing + const restClient = new NostrRestClient() const [profile, setProfile] = useState(profiles[post.pubkey]) @@ -103,25 +105,22 @@ function FeedCard (props) { } console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`) - writeRelays.map(async (relayUrl) => { - try { - // Sign the post - const signedEvent = finalizeEvent(eventTemplate, privateKeyBin) - console.log('signedEvent: ', signedEvent) - // Connect to a relay. - const relay = await Relay.connect(relayUrl) - console.log(`connected to ${relay.url}`) + // Sign the post + const signedEvent = finalizeEvent(eventTemplate, privateKeyBin) + console.log('signedEvent: ', signedEvent) - // Publish the message to the relay. - const result = await relay.publish(signedEvent) - console.log('result: ', result) + // Publish the like via REST API (handles broadcasting to multiple relays) + try { + const result = await restClient.publishEvent(signedEvent) + console.log('result: ', result) - // Close the connection to the relay. - relay.close() - } catch (err) { - console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`) + if (!result.accepted) { + throw new Error(`Failed to publish like: ${result.message || 'Unknown error'}`) } - }) + } catch (err) { + console.warn(`Error publishing like: ${err}`) + throw err + } await handleLikes(post, nostrKeyPair.pubHex) setLikesFetched(true) diff --git a/src/components/app-body/nostr/nostr-post/profile-post.js b/src/components/app-body/nostr/nostr-post/profile-post.js index 4bf4e62..3f44027 100644 --- a/src/components/app-body/nostr/nostr-post/profile-post.js +++ b/src/components/app-body/nostr/nostr-post/profile-post.js @@ -7,14 +7,16 @@ import React, { useState, useEffect } from 'react' import { Container, Form, Button, Spinner } from 'react-bootstrap' import Accordion from 'react-bootstrap/Accordion' import { finalizeEvent } from 'nostr-tools/pure' -import { Relay } from 'nostr-tools/relay' import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency +import NostrRestClient from '../../../../services/nostr-rest-client.js' // Local libraries function ProfilePost (props) { const { appData } = props - const { bchWalletState, writeRelays } = appData + const { bchWalletState } = appData + // Initialize REST client for publishing + const restClient = new NostrRestClient() const [accordionKey, setAccordionKey] = useState(null) const [onFetch, setOnFetch] = useState(false) const [formLoaded, setFormLoaded] = useState(false) @@ -88,23 +90,13 @@ function ProfilePost (props) { const signedEvent = finalizeEvent(eventTemplate, privateKeyBin) console.log('signedEvent: ', signedEvent) - // Publish the post to each relay. - writeRelays.map(async (relayUrl) => { - try { - // Connect to a relay. - const relay = await Relay.connect(relayUrl) - console.log(`connected to ${relay.url}`) + // Publish the profile via REST API (handles broadcasting to multiple relays) + const result = await restClient.publishEvent(signedEvent) + console.log('result: ', result) - // Publish the message to the relay. - const result = await relay.publish(signedEvent) - console.log('result: ', result) - - // Close the connection to the relay. - relay.close() - } catch (err) { - console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`) - } - }) + if (!result.accepted) { + throw new Error(`Failed to publish profile: ${result.message || 'Unknown error'}`) + } setSuccessMsg('Post successfully published!') setOnFetch(false) diff --git a/src/components/app-body/nostr/nostr-post/public-post.js b/src/components/app-body/nostr/nostr-post/public-post.js index f345021..441aeb9 100644 --- a/src/components/app-body/nostr/nostr-post/public-post.js +++ b/src/components/app-body/nostr/nostr-post/public-post.js @@ -7,15 +7,17 @@ import React, { useState } from 'react' import { Container, Form, Button, Spinner } from 'react-bootstrap' import Accordion from 'react-bootstrap/Accordion' import { finalizeEvent } from 'nostr-tools/pure' -import { Relay } from 'nostr-tools/relay' import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency +import NostrRestClient from '../../../../services/nostr-rest-client.js' // Local libraries function PublicPost (props) { const [accordionKey, setAccordionKey] = useState('0') const [onFetch, setOnFetch] = useState(false) - const { bchWalletState, writeRelays } = props.appData + const { bchWalletState } = props.appData + // Initialize REST client for publishing + const restClient = new NostrRestClient() const [formData, setFormData] = useState({ content: '' }) @@ -62,23 +64,13 @@ function PublicPost (props) { const signedEvent = finalizeEvent(eventTemplate, privateKeyBin) console.log('signedEvent: ', signedEvent) - // Publish the post to each relay. - writeRelays.map(async (relayUrl) => { - try { - // Connect to a relay. - const relay = await Relay.connect(relayUrl) - console.log(`connected to ${relay.url}`) + // Publish the post via REST API (handles broadcasting to multiple relays) + const result = await restClient.publishEvent(signedEvent) + console.log('result: ', result) - // Publish the message to the relay. - const result = await relay.publish(signedEvent) - console.log('result: ', result) - - // Close the connection to the relay. - relay.close() - } catch (err) { - console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`) - } - }) + if (!result.accepted) { + throw new Error(`Failed to publish post: ${result.message || 'Unknown error'}`) + } resetForm() setSuccessMsg('Post successfully published!') diff --git a/src/config/index.js b/src/config/index.js index bab4cc5..a876b2a 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -14,10 +14,13 @@ 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: 'http://localhost:5700', + // dexServer: 'https://dex-api.fullstack.cash', + dexServer: 'http://localhost:5700', nostrTopic: 'bch-dex-test-topic-02', + // REST API endpoint for Nostr relay interactions (primary interface) + nostrRestApiUrl: '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', nostrRelays: [ 'wss://nostr-relay.psfoundation.info', diff --git a/src/hooks/state.js b/src/hooks/state.js index 5e97897..2c804fb 100644 --- a/src/hooks/state.js +++ b/src/hooks/state.js @@ -63,8 +63,8 @@ function useAppState () { const [readRelays, setReadRelays] = useState(relaysData.filter(relay => relay.read).map(relay => relay.address)) // Read relays const [writeRelays, setWriteRelays] = useState(relaysData.filter(relay => relay.write).map(relay => relay.address)) // Write relays - // Nostr queries service - const nostrQueriesRef = useRef(new NostrQueries({ relays: readRelays })) + // Nostr queries service (REST API handles relays server-side, pass empty array for compatibility) + const nostrQueriesRef = useRef(new NostrQueries({ relays: [] })) // ProfileDM const [startChannelChat, setStartChannelChat] = useState('') @@ -139,7 +139,7 @@ function useAppState () { updateLocalStorage({ nftData: allCacheData }) // Update the local storage } - // Update relays data + // Update relays data (kept for UI compatibility, REST API handles relays server-side) function updateRelaysData (relaysData) { setRelaysData(relaysData) updateLocalStorage({ relays: relaysData }) // Update the local storage @@ -151,10 +151,12 @@ function useAppState () { const writeRelays = relaysData.filter(relay => relay.write).map(relay => relay.address) setWriteRelays(writeRelays) console.log('writeRelays: ', writeRelays) - nostrQueriesRef.current = new NostrQueries({ relays: readRelays }) + // NostrQueries no longer needs relay updates (REST API handles relays server-side) + // Recreate instance for compatibility, but pass empty array + nostrQueriesRef.current = new NostrQueries({ relays: [] }) } - // Restore relays data + // Restore relays data (kept for UI compatibility, REST API handles relays server-side) function restoreRelaysData () { const relaysData = [...localStorageDefault.relays] // Create a new array in order to detect changes setRelaysData(relaysData) @@ -167,7 +169,9 @@ function useAppState () { const writeRelays = relaysData.filter(relay => relay.write).map(relay => relay.address) setWriteRelays(writeRelays) console.log('writeRelays: ', writeRelays) - nostrQueriesRef.current = new NostrQueries({ relays: readRelays }) + // NostrQueries no longer needs relay updates (REST API handles relays server-side) + // Recreate instance for compatibility, but pass empty array + nostrQueriesRef.current = new NostrQueries({ relays: [] }) } // Update background state diff --git a/src/services/nostr-queries.js b/src/services/nostr-queries.js index d619a07..53a6d7c 100644 --- a/src/services/nostr-queries.js +++ b/src/services/nostr-queries.js @@ -1,8 +1,8 @@ /** * Nostr class for query into relay pools - * + * Refactored to use REST API instead of WebSocket */ -import { RelayPool } from 'nostr' +import NostrRestClient, { generateSubId } from './nostr-rest-client.js' import * as nip19 from 'nostr-tools/nip19' import { nip04 } from 'nostr-tools' import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency @@ -10,7 +10,11 @@ import axios from 'axios' export default class NostrQueries { constructor ({ relays }) { - this.relays = relays || [] + // Keep relays property for backward compatibility (empty array) + this.relays = [] + + // Initialize REST client + this.restClient = new NostrRestClient() this.loadedProfiles = {} this.loadedChannelsInfo = {} @@ -28,7 +32,8 @@ export default class NostrQueries { } setRelays (relays) { - this.relays = relays + // No-op for backward compatibility (REST API handles relays server-side) + this.relays = [] } npubToHex (npub) { @@ -41,160 +46,50 @@ export default class NostrQueries { return nip19.npubEncode(hex) } - // Load profile from nostr relays - // It uses multiple relays. It will exit after the first successful retrieval - // from any relay. If one relay fails, it will move on to the next one. + // Load profile from nostr relays via REST API async getProfile (pubHex) { try { - if (this.relays.length === 0) { - return false - } const existingProfile = this.loadedProfiles[pubHex] if (existingProfile) { console.log(`Returning profile from cache : ${existingProfile.name}`) return existingProfile } - for (let i = 0; i < this.relays.length; i++) { - const profile = await new Promise((resolve) => { - const relay = this.relays[i] - // const pool = RelayPool(config.nostrRelays) - const pool = RelayPool([relay]) - pool.on('open', relay => { - relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubHex] }) - }) + const subId = generateSubId('profile') + const filter = { limit: 5, kinds: [0], authors: [pubHex] } - pool.on('eose', relay => { - relay.close() - resolve(false) - }) + const events = await this.restClient.queryEvents(subId, filter) - pool.on('event', (relay, subId, ev) => { - try { - const profile = JSON.parse(ev.content) - // console.log('profile', profile) - console.log(`Profile found for ${pubHex} at ${relay.url}`) - resolve(profile) - } catch (error) { - resolve(false) - } - relay.close() - }) - pool.on('error', (relay) => { - console.log(`Error fetching ${pubHex} profile. relay connection error :${relay.url} `) - relay.close() - resolve(false) - }) - }) - // Stop looking for profile if found - if (profile) { - this.loadedProfiles[pubHex] = profile // Store profile + // Find the most recent profile event (kind 0 events are replaceable) + if (events && events.length > 0) { + // Sort by created_at descending to get most recent + events.sort((a, b) => b.created_at - a.created_at) + const profileEvent = events[0] + try { + const profile = JSON.parse(profileEvent.content) + console.log(`Profile found for ${pubHex}`) + this.loadedProfiles[pubHex] = profile return profile + } catch (error) { + console.warn(`Error parsing profile content for ${pubHex}:`, error) + return false } } + + return false } catch (error) { - console.warn(error) + console.warn(`Error fetching profile for ${pubHex}:`, error) + return false } } - // Get Feeds by user pubkey + // Get Feeds by user pubkey via REST API async getUserFeeds (pubHex) { try { - if (this.relays.length === 0) { - return [] - } - let feeds = await new Promise((resolve) => { - let list = [] - const closedRelays = [] + const subId = generateSubId('user-feeds') + const filter = { limit: 5, kinds: [1], authors: [pubHex] } - const pool = RelayPool(this.relays) - - pool.on('open', relay => { - relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] }) - }) - - pool.on('eose', relay => { - relay.close() - if (!closedRelays.includes(relay)) { - closedRelays.push(relay) - } - // Resolve list if all relays are closed - if (closedRelays.length === this.relays.length) { - resolve(list) - } - }) - - pool.on('event', (relay, subId, ev) => { - list = [...list, ev] - }) - pool.on('error', (relay) => { - relay.close() - if (!closedRelays.includes(relay)) { - closedRelays.push(relay) - } // Resolve list if all relays are closed - if (closedRelays.length === this.relays.length) { - resolve(list) - } - }) - }) - // Remove duplicated feeds - feeds = feeds.filter((val, i, list) => { - const existingIndex = list.findIndex(value => value.id === val.id) - return existingIndex === i - }) - - // Sort from newest to oldest - feeds.sort((a, b) => b.created_at - a.created_at) - - return feeds - } catch (error) { - console.warn(error) - } - } - - // Get global feeds - async getGlobalFeeds () { - try { - if (this.relays.length === 0) { - return [] - } - - let feeds = await new Promise((resolve, reject) => { - let list = [] - const closedRelays = [] - - const pool = RelayPool(this.relays) - // const pool = RelayPool([config.nostrRelay]) - pool.on('open', relay => { - relay.subscribe('REQ', { limit: 10, kinds: [1], '#t': ['slpdex-socialmedia'] }) - }) - - pool.on('eose', relay => { - relay.close() - if (!closedRelays.includes(relay)) { - closedRelays.push(relay) - } - // Resolve list if all relays are closed - if (closedRelays.length === this.relays.length) { - resolve(list) - } - }) - - pool.on('event', (relay, subId, ev) => { - console.log('post retrieved from ', relay.url, ev.sig) - list = [...list, ev] - }) - pool.on('error', (relay) => { - relay.close() - if (!closedRelays.includes(relay)) { - closedRelays.push(relay) - } - // Resolve list if all relays are closed - if (closedRelays.length === this.relays.length) { - resolve(list) - } - }) - }) + let feeds = await this.restClient.queryEvents(subId, filter) // Remove duplicated feeds feeds = feeds.filter((val, i, list) => { @@ -205,111 +100,74 @@ export default class NostrQueries { // Sort from newest to oldest feeds.sort((a, b) => b.created_at - a.created_at) - // console.log('feeds', feeds) - - return feeds + return feeds || [] } catch (error) { - console.warn(error) - } - } - - // Get follow list by pubkey - async getFollowList (pubHex) { - if (this.relays.length === 0) { + console.warn(`Error fetching user feeds for ${pubHex}:`, error) return [] } - return new Promise((resolve, reject) => { - let list = [] - const closedRelays = [] - - const pool = RelayPool(this.relays) - // const pool = RelayPool([config.nostrRelay]) - pool.on('open', relay => { - relay.subscribe('subid', { limit: 1, kinds: [3], authors: [pubHex] }) - }) - - pool.on('eose', relay => { - relay.close() - if (!closedRelays.includes(relay)) { - closedRelays.push(relay) - } - // Resolve list if all relays are closed - if (closedRelays.length === this.relays.length) { - resolve(list) - } - }) - - pool.on('event', (relay, subId, ev) => { - // console.log('Received event:', ev) - // Merge list received from all relays - list = [...list, ...ev.tags] - }) - pool.on('error', (relay) => { - relay.close() - if (!closedRelays.includes(relay)) { - closedRelays.push(relay) - } - // Resolve list if all relays are closed - if (closedRelays.length === this.relays.length) { - resolve(list) - } - }) - }) } - // Get event likes + // Get global feeds via REST API + async getGlobalFeeds () { + try { + const subId = generateSubId('global-feeds') + const filter = { limit: 10, kinds: [1], '#t': ['slpdex-socialmedia'] } + + let feeds = await this.restClient.queryEvents(subId, filter) + + // Remove duplicated feeds + feeds = feeds.filter((val, i, list) => { + const existingIndex = list.findIndex(value => value.id === val.id) + return existingIndex === i + }) + + // Sort from newest to oldest + feeds.sort((a, b) => b.created_at - a.created_at) + + return feeds || [] + } catch (error) { + console.warn('Error fetching global feeds:', error) + return [] + } + } + + // Get follow list by pubkey via REST API + async getFollowList (pubHex) { + try { + const subId = generateSubId('follow-list') + const filter = { limit: 1, kinds: [3], authors: [pubHex] } + + const events = await this.restClient.queryEvents(subId, filter) + + // Get the most recent follow list (kind 3 events are replaceable) + if (events && events.length > 0) { + // Sort by created_at descending to get most recent + events.sort((a, b) => b.created_at - a.created_at) + const followListEvent = events[0] + return followListEvent.tags || [] + } + + return [] + } catch (error) { + console.warn(`Error fetching follow list for ${pubHex}:`, error) + return [] + } + } + + // Get event likes via REST API async getPostLikes (postId) { try { - if (this.relays.length === 0) { - return [] - } - let likesRes = await new Promise((resolve) => { - const likes = [] - const closedRelays = [] + const subId = generateSubId('post-likes') + const filter = { kinds: [7], '#e': [postId] } - const pool = RelayPool(this.relays) - pool.on('open', relay => { - relay.subscribe('subid', { kinds: [7], '#e': [postId] }) - }) - - pool.on('eose', relay => { - relay.close() - if (!closedRelays.includes(relay)) { - closedRelays.push(relay) - } - // Resolve list if all relays are closed - if (closedRelays.length === this.relays.length) { - resolve(likes) - } - }) - - pool.on('event', (relay, subId, ev) => { - try { - // Count likes - if (ev.content === '+' || ev.content === '-') { - likes.push(ev) - } - } catch (error) { - // skip error - } - }) - pool.on('error', (relay) => { - relay.close() - if (!closedRelays.includes(relay)) { - closedRelays.push(relay) - } - // Resolve list if all relays are closed - if (closedRelays.length === this.relays.length) { - resolve(likes) - } - }) - }) + let likesRes = await this.restClient.queryEvents(subId, filter) // Remove duplicated events likesRes = likesRes.filter((val, i, list) => { const existingIndex = list.findIndex(value => value.id === val.id) return existingIndex === i }) + // Get likes const likesArr = likesRes.filter((val, i, list) => { return val.content === '+' @@ -319,129 +177,91 @@ export default class NostrQueries { const dislikesArr = likesRes.filter((val, i, list) => { return val.content === '-' }) + // For every user dislike remove a user like from the array for (let i = 0; i < dislikesArr.length; i++) { const disLike = dislikesArr[i] const likeExist = likesArr.findIndex(val => val.pubkey === disLike.pubkey) if (likeExist >= 0) likesArr.splice(likeExist, 1) } + // Return array of likes. return likesArr } catch (error) { - console.warn(error) + console.warn(`Error fetching post likes for ${postId}:`, error) + return [] } } async getChannelInfo (channelId) { try { - if (this.relays.length === 0) { - return false - } const existingChInfo = this.loadedChannelsInfo[channelId] if (existingChInfo) { console.log(`Returning ch info from cache : ${existingChInfo.name}`) return existingChInfo } - for (let i = 0; i < this.relays.length; i++) { - const info = await new Promise((resolve) => { - const relay = this.relays[i] - // const pool = RelayPool(config.nostrRelays) - const pool = RelayPool([relay]) - pool.on('open', relay => { - relay.subscribe('REQ', { limit: 1, kinds: [41], '#e': [channelId] }) - }) - pool.on('eose', relay => { - relay.close() - resolve(false) - }) + const subId = generateSubId('channel-info') + const filter = { limit: 1, kinds: [41], '#e': [channelId] } - pool.on('event', (relay, subId, ev) => { - try { - const chInfo = JSON.parse(ev.content) - resolve(chInfo) - } catch (error) { - resolve(false) - } - relay.close() - }) - pool.on('error', (relay) => { - relay.close() - resolve(false) - }) - }) - // Stop looking for profile if found - if (info) { - this.loadedChannelsInfo[channelId] = info - return info + const events = await this.restClient.queryEvents(subId, filter) + + if (events && events.length > 0) { + // Sort by created_at descending to get most recent + events.sort((a, b) => b.created_at - a.created_at) + const channelEvent = events[0] + try { + const chInfo = JSON.parse(channelEvent.content) + this.loadedChannelsInfo[channelId] = chInfo + return chInfo + } catch (error) { + console.warn(`Error parsing channel info for ${channelId}:`, error) + return false } } + + return false } catch (error) { - console.warn(error) + console.warn(`Error fetching channel info for ${channelId}:`, error) + return false } } - // Get associated pub keys from kind 04 inbox + // Get associated pub keys from kind 04 inbox via REST API async getDms (pubKey) { try { - if (this.relays.length === 0) { - return [] + const subId = generateSubId('dms') + // Use array of filters for multiple conditions + const filters = [ + { limit: 100, kinds: [4], '#p': [pubKey] }, // received messages + { limit: 100, kinds: [4], authors: [pubKey] } // sent messages + ] + + const events = await this.restClient.queryEvents(subId, filters) + + const list = [] + for (const ev of events) { + if (ev.pubkey === pubKey) { + // Sent message - get recipient from tags + if (ev.tags && ev.tags.length > 0 && ev.tags[0][0] === 'p') { + list.push(ev.tags[0][1]) + } + } else { + // Received message - get sender pubkey + list.push(ev.pubkey) + } } - let dms = await new Promise((resolve, reject) => { - let list = [] - const closedRelays = [] - - const pool = RelayPool(this.relays) - // const pool = RelayPool([config.nostrRelay]) - pool.on('open', relay => { - relay.subscribe('REQ', [ - { limit: 100, kinds: [4], '#p': [pubKey] }, // received messages - { limit: 100, kinds: [4], authors: [pubKey] } // sent messages - ]) - }) - - pool.on('eose', relay => { - relay.close() - if (!closedRelays.includes(relay)) { - closedRelays.push(relay) - } - // Resolve list if all relays are closed - if (closedRelays.length === this.relays.length) { - resolve(list) - } - }) - - pool.on('event', (relay, subId, ev) => { - // console.log('post retrieved from ', relay.url, ev.sig) - if (ev.pubkey === pubKey) { - const pk = ev.tags[0][1] - list = [...list, pk] - } else { - list = [...list, ev.pubkey] - } - }) - pool.on('error', (relay) => { - relay.close() - if (!closedRelays.includes(relay)) { - closedRelays.push(relay) - } - // Resolve list if all relays are closed - if (closedRelays.length === this.relays.length) { - resolve(list) - } - }) - }) - - // Remove duplicated feeds - dms = dms.filter((val, i, list) => { + // Remove duplicated pubkeys + const dms = list.filter((val, i, list) => { const existingIndex = list.findIndex(value => value === val) return existingIndex === i }) return dms } catch (error) { - console.warn(error) + console.warn(`Error fetching DMs for ${pubKey}:`, error) + return [] } } diff --git a/src/services/nostr-rest-client.js b/src/services/nostr-rest-client.js new file mode 100644 index 0000000..facf2d3 --- /dev/null +++ b/src/services/nostr-rest-client.js @@ -0,0 +1,239 @@ +/** + * REST API Client for Nostr relay interactions via REST2NOSTR proxy + */ + +import config from '../config/index.js' + +/** + * Generate a unique subscription ID + * @param {string} prefix - 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)}` +} + +class NostrRestClient { + constructor (localConfig = {}) { + this.apiUrl = localConfig.apiUrl || config.nostrRestApiUrl + this.activeSubscriptions = new Map() // Track active SSE subscriptions + } + + /** + * Publish a signed event to the relay + * @param {Object} signedEvent - Signed Nostr event + * @returns {Promise} Response with {accepted, message, eventId} + */ + async publishEvent (signedEvent) { + try { + const response = await fetch(`${this.apiUrl}/event`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(signedEvent) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP ${response.status}: ${errorText}`) + } + + const result = await response.json() + return result + } catch (error) { + console.error('Error publishing event:', error) + throw error + } + } + + /** + * Query events statelessly (GET request) + * @param {string} subId - Subscription ID + * @param {Array|Object} filters - Filter object or array of filters + * @returns {Promise} Array of events + */ + async queryEvents (subId, filters) { + try { + // Ensure filters is an array + const filtersArray = Array.isArray(filters) ? filters : [filters] + + // Encode filters as query parameter + const filtersJson = encodeURIComponent(JSON.stringify(filtersArray)) + const url = `${this.apiUrl}/req/${subId}?filters=${filtersJson}` + + const response = await fetch(url) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP ${response.status}: ${errorText}`) + } + + const events = await response.json() + return events + } catch (error) { + console.error('Error querying events:', error) + throw error + } + } + + /** + * Create a subscription for Server-Sent Events (SSE) + * @param {string} subId - Subscription ID + * @param {Array|Object} filters - Filter object or array of filters + * @param {Object} callbacks - Callback functions {onEvent, onEose, onClosed, onError} + * @returns {EventSource} EventSource instance for cleanup + */ + createSubscription (subId, filters, callbacks = {}) { + const { onEvent, onEose, onClosed, onError } = callbacks + + // Ensure filters is an array + const filtersArray = Array.isArray(filters) ? filters : [filters] + + // Use POST method for SSE subscription + // Note: EventSource doesn't support POST, so we'll use fetch with streaming + // However, for simplicity and browser compatibility, we'll use a workaround: + // Create a form and use POST, or use EventSource with GET if the server supports it + + // For now, we'll use fetch with streaming and parse SSE manually + // This is more complex but allows POST with filters in body + const abortController = new AbortController() + + const fetchSubscription = async () => { + try { + const response = await fetch(`${this.apiUrl}/req/${subId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream' + }, + body: JSON.stringify(filtersArray), + signal: abortController.signal + }) + + if (!response.ok) { + const errorText = await response.text() + if (onError) { + onError(new Error(`HTTP ${response.status}: ${errorText}`)) + } + return + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' // Keep incomplete line in buffer + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)) // Remove 'data: ' prefix + + if (data.type === 'connected') { + // Connection established + console.log(`Subscription ${subId} connected`) + } else if (data.type === 'event' && data.data) { + // Event received + if (onEvent) { + onEvent(data.data) + } + } else if (data.type === 'eose') { + // End of stored events + if (onEose) { + onEose() + } + } else if (data.type === 'closed') { + // Subscription closed + if (onClosed) { + onClosed(data.message || 'Subscription closed') + } + return // Stop reading + } + } catch (parseError) { + console.warn('Error parsing SSE message:', parseError, line) + } + } + } + } + } catch (error) { + if (error.name === 'AbortError') { + // Subscription was cancelled, this is expected + return + } + console.error('Error in SSE subscription:', error) + if (onError) { + onError(error) + } + } + } + + // Store subscription for cleanup + const subscription = { + subId, + abortController, + close: () => { + abortController.abort() + this.closeSubscription(subId) + } + } + + this.activeSubscriptions.set(subId, subscription) + + // Start the subscription + fetchSubscription() + + return subscription + } + + /** + * Close an existing subscription + * @param {string} subId - Subscription ID to close + * @returns {Promise} + */ + async closeSubscription (subId) { + try { + // Abort the fetch if it's still active + const subscription = this.activeSubscriptions.get(subId) + if (subscription && subscription.abortController) { + subscription.abortController.abort() + } + + // Send DELETE request to close subscription + const response = await fetch(`${this.apiUrl}/req/${subId}`, { + method: 'DELETE' + }) + + if (!response.ok) { + const errorText = await response.text() + console.warn(`Error closing subscription ${subId}:`, errorText) + } + + // Remove from active subscriptions + this.activeSubscriptions.delete(subId) + } catch (error) { + console.error(`Error closing subscription ${subId}:`, error) + // Still remove from active subscriptions even if DELETE fails + this.activeSubscriptions.delete(subId) + } + } + + /** + * Close all active subscriptions + */ + async closeAllSubscriptions () { + const subIds = Array.from(this.activeSubscriptions.keys()) + await Promise.all(subIds.map(subId => this.closeSubscription(subId))) + } +} + +export default NostrRestClient diff --git a/src/services/nostr.js b/src/services/nostr.js index 6340de5..f5f284e 100644 --- a/src/services/nostr.js +++ b/src/services/nostr.js @@ -10,10 +10,10 @@ // import * as nip19 from '@chris.troutner/nostr-tools/nip19' import { finalizeEvent } from 'nostr-tools/pure' -import { Relay } from 'nostr-tools/relay' import BchNostr from 'bch-nostr' import * as nip19 from 'nostr-tools/nip19' import config from '../config/index.js' +import NostrRestClient from './nostr-rest-client.js' class NostrBrowser { constructor (localConfig = {}) { @@ -27,6 +27,9 @@ class NostrBrowser { relayWs: config.nostrRelay, topic: config.nostrTopic }) + + // Initialize REST client for publishing events + this.restClient = new NostrRestClient() } async testNostrUpload (inObj = {}) { @@ -69,7 +72,6 @@ class NostrBrowser { // tags: [['t', 'bch-dex-test-topic-01']] // } - const relayWs = config.nostrRelay const eventTemplate = { kind: 867, created_at: Math.floor(Date.now() / 1000), @@ -82,18 +84,12 @@ class NostrBrowser { // console.log('signedEvent: ', signedEvent) const eventId = signedEvent.id - // Connect to a relay. - const relay = await Relay.connect(relayWs, { - /* global WebSocket */ - webSocket: WebSocket - }) - // console.log(`connected to ${relay.url}`) + // Publish the message via REST API + const result = await this.restClient.publishEvent(signedEvent) - // Publish the message to the relay. - await relay.publish(signedEvent) - - // Close the connection to the relay. - relay.close() + if (!result.accepted) { + throw new Error(`Failed to publish event: ${result.message || 'Unknown error'}`) + } // const eventId = await this.bchNostr.post.uploadToNostr(inObj)