From 680f0c8662853e55ca818f5a60c26726fe2f8a48 Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Thu, 4 Sep 2025 21:18:53 -0400 Subject: [PATCH] feat(nostr): Added NIP04 private messaging --- .../app-body/nostr-chat/chat-header.js | 11 +- src/components/app-body/nostr-chat/dm-item.js | 26 +- src/components/app-body/nostr-chat/dm-list.js | 10 +- src/components/app-body/nostr-chat/index.js | 225 ++++++++++++++++-- .../app-body/nostr-chat/message-input.js | 82 ++++++- .../app-body/nostr-chat/message-item.js | 17 +- .../app-body/nostr-chat/profile-menu.js | 12 +- src/services/nostr-queries.js | 109 ++++++++- 8 files changed, 441 insertions(+), 51 deletions(-) diff --git a/src/components/app-body/nostr-chat/chat-header.js b/src/components/app-body/nostr-chat/chat-header.js index 7cb1deb..14497ba 100644 --- a/src/components/app-body/nostr-chat/chat-header.js +++ b/src/components/app-body/nostr-chat/chat-header.js @@ -6,7 +6,7 @@ import React, { useState, useCallback, useEffect } from 'react' import { Spinner } from 'react-bootstrap' function ChatHeader (props) { - const { selectedChannel, channelsData } = props + const { selectedChannel, channelsData, selectedChannelIsDm, profiles } = props const [chInfo, setChInfo] = useState() // channel data const getShortName = useCallback((str) => { if (str.length < 20) return str @@ -20,10 +20,15 @@ function ChatHeader (props) { // get channel info useEffect(() => { - if (!chInfo && channelsData[selectedChannel]) { + // Public channel + if (!chInfo && !selectedChannelIsDm && channelsData[selectedChannel]) { setChInfo(channelsData[selectedChannel]) } - }, [chInfo, channelsData, selectedChannel]) + // Dm Channel + if (!chInfo && selectedChannelIsDm && profiles[selectedChannel]) { + setChInfo(profiles[selectedChannel]) + } + }, [chInfo, channelsData, selectedChannel, profiles, selectedChannelIsDm]) return (
{chInfo && diff --git a/src/components/app-body/nostr-chat/dm-item.js b/src/components/app-body/nostr-chat/dm-item.js index a9df2bb..b99fb08 100644 --- a/src/components/app-body/nostr-chat/dm-item.js +++ b/src/components/app-body/nostr-chat/dm-item.js @@ -3,19 +3,33 @@ */ // Global npm libraries -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUser } from '@fortawesome/free-solid-svg-icons' export default function DMItem (props) { const { dm, profiles, selectedChannel, onChangeChannel } = props const { pubKey } = dm - const profile = profiles[pubKey] + + const [profile, setProfile] = useState(profiles[pubKey]) const isSelected = selectedChannel === pubKey + useEffect(() => { + const profile = profiles[pubKey] + + if (profile) { + setProfile(profile) + } + }, [profiles, pubKey]) + const getShortName = useCallback((str) => { - if (str.length < 20) return str - return str.slice(0, 4) + '...' + str.slice(-4) + if (!str || !str.startsWith('npub')) return str + + const prefix = 'npub' + const first4 = str.slice(prefix.length, prefix.length + 4) + const last4 = str.slice(-4) + + return `${prefix}:${first4}...${last4}` }, []) const handleClick = () => { @@ -84,12 +98,12 @@ export default function DMItem (props) { > {getShortName(profile?.name) || 'Unknown User'}
-
0 ? 'fw-bold' : ''}`} style={{ fontSize: '12px' }} > No messages yet -
+ */} {/* Unread indicator */} diff --git a/src/components/app-body/nostr-chat/dm-list.js b/src/components/app-body/nostr-chat/dm-list.js index 72d2911..6002e30 100644 --- a/src/components/app-body/nostr-chat/dm-list.js +++ b/src/components/app-body/nostr-chat/dm-list.js @@ -7,11 +7,12 @@ import React from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUser } from '@fortawesome/free-solid-svg-icons' import DMItem from './dm-item' +import { Spinner } from 'react-bootstrap' function DMList (props) { - const { dmChannels, profiles, selectedChannel, onChangeChannel } = props + const { dmChannels, profiles, selectedChannel, onChangeChannel, dmListLoaded } = props - if (dmChannels.length === 0) { + if (dmChannels.length === 0 && dmListLoaded) { return (
@@ -37,6 +38,11 @@ function DMList (props) { {...props} /> ))} + {!dmListLoaded && ( +
+ +
+ )}
) } diff --git a/src/components/app-body/nostr-chat/index.js b/src/components/app-body/nostr-chat/index.js index 8569eef..46025e6 100644 --- a/src/components/app-body/nostr-chat/index.js +++ b/src/components/app-body/nostr-chat/index.js @@ -14,7 +14,7 @@ import config from '../../../config' function NostrChat (props) { const { appData } = props - const { nostrQueries } = appData + const { nostrQueries, bchWalletState } = appData const [messages, setMessages] = useState([]) const [loadedMessages, setLoadedMessages] = useState(false) const [profiles, setProfiles] = useState({}) @@ -22,23 +22,27 @@ function NostrChat (props) { const [channelsLoaded, setChannelsLoaded] = useState(false) const [groupChannels] = useState(config.chatsId) const [dmChannels, setDmChannels] = useState([]) + const [dmListLoaded, setDmListLoaded] = useState(false) const [selectedChannel, setSelectedChannel] = useState(config.chatsId[0]) + const [selectedChannelIsDm, setSelectedChannelIsDm] = useState(false) + const profilesRef = useRef({}) + const dmChannelsRef = useRef([]) // Handle read messages - const onMsgRead = useCallback(async (msg) => { + const onMsgRead = useCallback(async (ev) => { try { // console.log('onMsgRead() msg: ', msg) // Update messages list setMessages(current => { // ignore existing messages - const exist = current.find(val => val.id === msg.id) + const exist = current.find(val => val.id === ev.id) if (exist) return current const newMsgs = [...current] - newMsgs.push(msg) + newMsgs.push(ev) // Sort messages by timestamp newMsgs.sort((a, b) => b.created_at - a.created_at) @@ -46,21 +50,21 @@ function NostrChat (props) { }) // Fetch message owner profile - const pubKey = msg.pubkey - const existProfile = profilesRef.current[msg.pubkey] + const pubKey = ev.pubkey + const existProfile = profilesRef.current[ev.pubkey] if (existProfile) { return } + const npub = await appData.nostrQueries.hexToNpub(pubKey) console.log(`Trying to get ${pubKey} profile.`) - const defaultProfile = { name: pubKey } // default profile + const defaultProfile = { name: npub } // default profile profilesRef.current[pubKey] = defaultProfile // update ref , to prevent fetch this profile again. // Fetch profile const nostrProfile = await appData.nostrQueries.getProfile(pubKey) const profile = nostrProfile || defaultProfile - const npub = await appData.nostrQueries.hexToNpub(pubKey) // add public key formats to profile object profile.pubKey = pubKey profile.npub = npub @@ -76,11 +80,34 @@ function NostrChat (props) { } }, [appData, profilesRef]) - // Handle nostr pool + const decryptMsg = useCallback(async ({ ev, pubKey }) => { + try { + const encryptedMsg = ev.content + const senderPubKey = pubKey + + const { nostrKeyPair } = appData.bchWalletState + const { nostrQueries } = appData + const decryptData = { + receiverPrivKey: nostrKeyPair.privHex, + senderPubKey, + encryptedMsg + } + + const decrptedMsg = await nostrQueries.decryptMsg(decryptData) + + ev.content = decrptedMsg + onMsgRead(ev) + } catch (error) { + console.warn(error) + } + }, [appData, onMsgRead]) + + // Handle nostr pool for group channels useEffect(() => { // fetch messages when channel selected and channel metadata are loaded - if (!selectedChannel || !channelsLoaded) return + if (!selectedChannel || !channelsLoaded || selectedChannelIsDm) return + // Load messages for group channel const relays = nostrQueries.relays if (relays.length === 0) { return @@ -94,7 +121,9 @@ function NostrChat (props) { }) pool.on('eose', relay => { - setLoadedMessages(true) + if (!selectedChannelIsDm) { + setLoadedMessages(true) + } }) pool.on('event', (relay, subId, ev) => { @@ -104,12 +133,161 @@ function NostrChat (props) { return () => { // Close pool on component unmount or selected channel changes - console.log('Close existing pool') + console.log('Close existing pool for group channel') pool.close() } - }, [onMsgRead, selectedChannel, nostrQueries, channelsLoaded]) + }, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, channelsLoaded]) - // Load channels data + // Handle nostr pool for dm channels + useEffect(() => { + // fetch messages when channel selected and channel metadata are loaded + console.log('selectedChannel', selectedChannel) + console.log('selectedChannelIsDm', selectedChannelIsDm) + + if (!selectedChannel || !selectedChannelIsDm) return + const { nostrKeyPair } = bchWalletState + const dmPubKey = selectedChannel + console.log('dmPubKey', selectedChannel) + + // Load messages for group channel + const relays = nostrQueries.relays + if (relays.length === 0) { + return + } + + 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) + } + }) + + pool.on('event', (relay, subId, ev) => { + console.log('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 }) + } + }) + + return () => { + // Close pool on component unmount or selected channel changes + console.log('Close existing pool for private channel') + pool.close() + } + }, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, bchWalletState, decryptMsg]) + + const handleIncomingDms = useCallback(async (pubKey) => { + try { + if (!dmListLoaded) return + const exist = dmChannelsRef.current.find(val => val === pubKey) + if (exist) return + + let profile = await nostrQueries.getProfile(pubKey) + const npub = nostrQueries.hexToNpub(pubKey) + if (!profile) profile = { name: npub } + // add public key formats to profile object + profile.pubKey = pubKey + profile.npub = npub + setProfiles(currentProfiles => { + const newProfiles = { ...currentProfiles } + newProfiles[pubKey] = profile + profilesRef.current = newProfiles + return newProfiles + }) + setDmChannels(currentChs => { + const newChs = [...currentChs] + newChs.push(profile.pubKey) + dmChannelsRef.current = newChs + return newChs + }) + setChannelsData(currentChs => { + const newChs = { ...currentChs } + newChs[profile.pubKey] = profile + return newChs + }) + } catch (error) { + console.warn(error) + } + }, [nostrQueries, dmListLoaded]) + + // Keep live NPI04 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 + ]) + }) + + pool.on('event', (relay, subId, ev) => { + console.log('New message received', ev) + handleIncomingDms(ev.pubkey) + }) + + return () => { + // Close pool on component unmount or selected channel changes + console.log('Close existing pool for private channel') + pool.close() + } + }, [handleIncomingDms, appData, nostrQueries, dmListLoaded, channelsLoaded]) + + // Load Dm channels + useEffect(() => { + const loadCurrentDms = async () => { + const { nostrKeyPair } = bchWalletState + const dms = await appData.nostrQueries.getDms(nostrKeyPair.pubHex) + console.log('dm list', dms) + + for (let i = 0; i < dms.length; i++) { + const pubKey = dms[i] + const npub = await appData.nostrQueries.hexToNpub(pubKey) + + const defaultProfile = { name: npub } // default profile + profilesRef.current[pubKey] = defaultProfile // update ref , to prevent fetch this profile again. + // Fetch profile + const nostrProfile = await appData.nostrQueries.getProfile(pubKey) + + const profile = nostrProfile || defaultProfile + // add public key formats to profile object + profile.pubKey = pubKey + profile.npub = npub + // Update profiles state + setProfiles(currentProfiles => { + const newProfiles = { ...currentProfiles } + newProfiles[pubKey] = profile + profilesRef.current = newProfiles + return newProfiles + }) + } + setDmChannels(dms) + setDmListLoaded(true) + dmChannelsRef.current = dms + } + + loadCurrentDms() + }, [bchWalletState, appData.nostrQueries]) + + // Load public channels data useEffect(() => { const loadChData = async () => { const loadedChannels = [] @@ -142,12 +320,14 @@ function NostrChat (props) { const addPrivateMessage = async (profile) => { try { const exist = dmChannels.find(val => val === profile.pubKey) - - setSelectedChannel(profile.pubKey) + setMessages([]) + setLoadedMessages(false) + onChangeChannel(profile.pubKey) if (exist) return setDmChannels(currentChs => { const newChs = [...currentChs] newChs.push(profile.pubKey) + dmChannelsRef.current = newChs return newChs }) setChannelsData(currentChs => { @@ -155,10 +335,6 @@ function NostrChat (props) { newChs[profile.pubKey] = profile return newChs }) - - setMessages([]) - - // setSelectedChannel(profile.pubKey) } catch (error) { console.warn(error) } @@ -167,10 +343,11 @@ function NostrChat (props) { // Reset states on change channel const onChangeChannel = useCallback((ch) => { if (selectedChannel === ch) return - setSelectedChannel(ch) + setSelectedChannelIsDm(!!profiles[ch]) setMessages([]) setLoadedMessages(false) - }, [selectedChannel]) + setSelectedChannel(ch) + }, [selectedChannel, profiles]) return ( <> @@ -181,9 +358,11 @@ function NostrChat (props) { groupChannels={groupChannels} dmChannels={dmChannels} selectedChannel={selectedChannel} + selectedChannelIsDm={selectedChannelIsDm} profiles={profiles} channelsData={channelsData} onChangeChannel={onChangeChannel} + dmListLoaded={dmListLoaded && channelsLoaded} {...props} /> @@ -191,9 +370,11 @@ function NostrChat (props) { { + // Define input type between private or public + useEffect(() => { + const dmTo = profiles[selectedChannel] + setIsDm(!!dmTo) + setDmProfile(dmTo) + }, [selectedChannel, profiles]) + + const handleSubmitPrivate = async (e) => { e.preventDefault() + try { + setOnFetch(true) + console.log('dm To : ', dmProfile) + + const { nostrKeyPair } = bchWalletState + // Convert private key to binary + const privateKeyBin = hexToBytes(nostrKeyPair.privHex) + + const encryptedMsg = await nostrQueries.encryptMsg({ + senderPrivKey: nostrKeyPair.privHex, + receiverPubKey: dmProfile?.pubKey, + message + }) + + console.log('encryptedMsg', encryptedMsg) + // Generate a post. + const eventTemplate = { + kind: 4, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', dmProfile.pubKey]], + content: encryptedMsg + } + console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`) + + // Sign the post + 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] + + 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}`) + } + } + setMessage('') + setOnFetch(false) + } catch (error) { + console.warn(error) + setOnFetch(false) + } + } + + // Post on nostr network + const handleSubmitPublic = async (e) => { + e.preventDefault() try { setOnFetch(true) @@ -77,12 +143,12 @@ function MessageInput (props) { } return (
-
+