From 1ff9152718d8c7cde9bc0b3bceca7cfe00a83341 Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Wed, 27 Aug 2025 17:49:16 -0400 Subject: [PATCH] feat(nostr): Created nostr general chat interface --- src/components/app-body/index.js | 2 + .../app-body/nostr-chat/channel-item.js | 58 +++++++ .../app-body/nostr-chat/channel-list.js | 29 ++++ .../app-body/nostr-chat/chat-header.js | 43 +++++ .../app-body/nostr-chat/chat-main.js | 48 ++++++ .../app-body/nostr-chat/chat-sidebar.js | 35 ++++ .../app-body/nostr-chat/date-separator.js | 49 ++++++ src/components/app-body/nostr-chat/index.js | 160 ++++++++++++++++++ .../app-body/nostr-chat/message-input.js | 124 ++++++++++++++ .../app-body/nostr-chat/message-item.js | 97 +++++++++++ .../app-body/nostr-chat/message-list.js | 98 +++++++++++ .../app-body/nostr-chat/sample-data.js | 42 +++++ src/components/nav-menu/index.js | 7 + src/config/index.js | 3 + src/services/nostr-queries.js | 47 +++++ 15 files changed, 842 insertions(+) create mode 100644 src/components/app-body/nostr-chat/channel-item.js create mode 100644 src/components/app-body/nostr-chat/channel-list.js create mode 100644 src/components/app-body/nostr-chat/chat-header.js create mode 100644 src/components/app-body/nostr-chat/chat-main.js create mode 100644 src/components/app-body/nostr-chat/chat-sidebar.js create mode 100644 src/components/app-body/nostr-chat/date-separator.js create mode 100644 src/components/app-body/nostr-chat/index.js create mode 100644 src/components/app-body/nostr-chat/message-input.js create mode 100644 src/components/app-body/nostr-chat/message-item.js create mode 100644 src/components/app-body/nostr-chat/message-list.js create mode 100644 src/components/app-body/nostr-chat/sample-data.js diff --git a/src/components/app-body/index.js b/src/components/app-body/index.js index bf4d6bb..cccf610 100644 --- a/src/components/app-body/index.js +++ b/src/components/app-body/index.js @@ -29,6 +29,7 @@ import Feeds from './nostr/feeds/index.js' import ContentCreators from './nostr/content-creators/index.js' import UserDataReview from './user-data-review' import Offers from './offers' +import NostrChat from './nostr-chat' function AppBody (props) { // Dependency injection through props const appData = props.appData @@ -54,6 +55,7 @@ function AppBody (props) { } /> } /> } /> + } /> {/** Show in all paths except the servers view */} {/* {appData.currentPath !== '/servers' && } */} diff --git a/src/components/app-body/nostr-chat/channel-item.js b/src/components/app-body/nostr-chat/channel-item.js new file mode 100644 index 0000000..4a47699 --- /dev/null +++ b/src/components/app-body/nostr-chat/channel-item.js @@ -0,0 +1,58 @@ +/* + Component for displaying channel item +*/ + +// Global npm libraries +import React, { useCallback, useEffect, useState } from 'react' +import { ListGroup, Spinner } from 'react-bootstrap' + +export default function ChannelItem (props) { + const { channel, selectedChannel, onChangeChannel, channelsData } = props + const [chInfo, setChInfo] = useState() + + const getShortName = useCallback((str) => { + return str.slice(0, 8) + '...' + str.slice(-5) + }, []) + + useEffect(() => { + if (!chInfo && channelsData[channel]) { + setChInfo(channelsData[channel]) + } + }, [chInfo, channelsData, channel]) + + return ( + { onChangeChannel(channel) }} + key={channel} + className='border-0 bg-transparent text-dark' + style={{ + cursor: 'pointer', + backgroundColor: selectedChannel === channel ? '#6f42c1' : 'transparent', + borderRadius: '8px', + marginBottom: '4px', + padding: '10px 12px', + transition: 'all 0.2s ease', + border: selectedChannel === channel ? 'none' : '1px solid transparent', + boxShadow: selectedChannel === channel ? '0 2px 4px rgba(111, 66, 193, 0.15)' : 'none' + }} + > + {chInfo?.name && +
+ + {chInfo?.name || getShortName(channel)} + +
} + {!chInfo?.name && +
+ {getShortName(channel)} +
} +
+ ) +} diff --git a/src/components/app-body/nostr-chat/channel-list.js b/src/components/app-body/nostr-chat/channel-list.js new file mode 100644 index 0000000..d9b68f2 --- /dev/null +++ b/src/components/app-body/nostr-chat/channel-list.js @@ -0,0 +1,29 @@ +/* + Component for displaying the list of available channels +*/ + +// Global npm libraries +import React from 'react' +import { ListGroup } from 'react-bootstrap' +import ChannelItem from './channel-item' +function ChannelList (props) { + const { channels, selectedChannel } = props + + if (!channels || channels.length === 0) { + return ( +
+ No channels available +
+ ) + } + + return ( + + {channels.map((channel, i) => ( + + ))} + + ) +} + +export default ChannelList diff --git a/src/components/app-body/nostr-chat/chat-header.js b/src/components/app-body/nostr-chat/chat-header.js new file mode 100644 index 0000000..faa4320 --- /dev/null +++ b/src/components/app-body/nostr-chat/chat-header.js @@ -0,0 +1,43 @@ +/* + Component for the chat header displaying chat info +*/ + +// Global npm libraries +import React, { useState, useCallback, useEffect } from 'react' +import { Spinner } from 'react-bootstrap' +function ChatHeader (props) { + const { selectedChannel, channelsData } = props + const [chInfo, setChInfo] = useState() // channel data + const getShortName = useCallback((str) => { + return str.slice(0, 8) + '...' + str.slice(-5) + }, []) + + // Restore ch info + useEffect(() => { + setChInfo(null) + }, [selectedChannel]) + + // get channel info + useEffect(() => { + if (!chInfo && channelsData[selectedChannel]) { + setChInfo(channelsData[selectedChannel]) + } + }, [chInfo, channelsData, selectedChannel]) + return ( +
+ {chInfo && +
+
{chInfo?.name || getShortName(selectedChannel)}
+ {chInfo?.about || ''} +
} + + {!chInfo && +
+ + +
} +
+ ) +} + +export default ChatHeader diff --git a/src/components/app-body/nostr-chat/chat-main.js b/src/components/app-body/nostr-chat/chat-main.js new file mode 100644 index 0000000..17399c9 --- /dev/null +++ b/src/components/app-body/nostr-chat/chat-main.js @@ -0,0 +1,48 @@ +/* + Component for the main chat area +*/ + +// Global npm libraries +import React from 'react' + +// Local libraries +import ChatHeader from './chat-header' +import MessageList from './message-list' +import MessageInput from './message-input' + +function ChatMain (props) { + const { selectedChannel, messages } = props + + return ( + <> + {!selectedChannel && ( +
+
+

Select a channel to start chatting

+
+
+ )} + + {selectedChannel && ( +
+ {/* Chat Header */} +
+ +
+ + {/* Messages Area */} +
+ +
+ + {/* Message Input */} +
+ +
+
+ )} + + ) +} + +export default ChatMain diff --git a/src/components/app-body/nostr-chat/chat-sidebar.js b/src/components/app-body/nostr-chat/chat-sidebar.js new file mode 100644 index 0000000..eacce60 --- /dev/null +++ b/src/components/app-body/nostr-chat/chat-sidebar.js @@ -0,0 +1,35 @@ +/* + Component for the chat sidebar channels +*/ + +// Global npm libraries +import React from 'react' + +// Local libraries +import ChannelList from './channel-list' + +function ChatSidebar (props) { + const { channels, selectedChannel } = props + + return ( +
+ {/* Channels Section */} +
+
+
+
+ Channels +
+
+ +
+
+
+ ) +} + +export default ChatSidebar diff --git a/src/components/app-body/nostr-chat/date-separator.js b/src/components/app-body/nostr-chat/date-separator.js new file mode 100644 index 0000000..3f328b2 --- /dev/null +++ b/src/components/app-body/nostr-chat/date-separator.js @@ -0,0 +1,49 @@ +/* + Component for displaying date separators between message groups. +*/ + +// Global npm libraries +import React from 'react' + +function DateSeparator (props) { + const { date } = props + + // Format date for display + const formatDate = (dateString) => { + const date = new Date(dateString) + const today = new Date() + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + + if (date.toDateString() === today.toDateString()) { + return 'Today' + } else if (date.toDateString() === yesterday.toDateString()) { + return 'Yesterday' + } else { + return date.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }) + } + } + + return ( +
+
+ {formatDate(date)} +
+
+ ) +} + +export default DateSeparator diff --git a/src/components/app-body/nostr-chat/index.js b/src/components/app-body/nostr-chat/index.js new file mode 100644 index 0000000..dd47b1a --- /dev/null +++ b/src/components/app-body/nostr-chat/index.js @@ -0,0 +1,160 @@ +/* + Component for Nostr Chat functionality +*/ + +// Global npm libraries +import React, { useCallback, useEffect, useState, useRef } from 'react' +import { Container, Row, Col } from 'react-bootstrap' +import { RelayPool } from 'nostr' +// Local libraries +import ChatSidebar from './chat-sidebar' +import ChatMain from './chat-main' +import config from '../../../config' + +function NostrChat (props) { + const { appData } = props + const { nostrQueries } = appData + const [messages, setMessages] = useState([]) + const [loadedMessages, setLoadedMessages] = useState(false) + const [profiles, setProfiles] = useState({}) + const [channelsData, setChannelsData] = useState({}) + const [channels] = useState(config.chatsId) + + const [selectedChannel, setSelectedChannel] = useState(config.chatsId[0]) + const profilesRef = useRef({}) + + // Handle read messages + const onMsgRead = useCallback(async (msg) => { + try { + // Update messages list + setMessages(current => { + const exist = current.find(val => val.id === msg.id) + // ignore existing messages + if (exist) return current + + const newMsgs = [...current] + newMsgs.push(msg) + // Sort messages by timestamp + newMsgs.sort((a, b) => b.created_at - a.created_at) + return newMsgs.reverse() + }) + + // Fetch message owner profile + const pubKey = msg.pubkey + const existProfile = profilesRef.current[msg.pubkey] + if (existProfile) { + return + } + // Fech profile. + let profile = await appData.nostrQueries.getProfile(pubKey) + if (!profile) profile = { name: pubKey } + // Update profiles state + setProfiles(currentProfiles => { + const newProfiles = { ...currentProfiles } + newProfiles[pubKey] = profile + profilesRef.current = newProfiles + return newProfiles + }) + } catch (error) { + console.warn(error) + } + }, [appData, profilesRef]) + + // Handle nostr pool + useEffect(() => { + if (!selectedChannel) return + + 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: [42], '#e': [selectedChannel] }) + }) + pool.on('eose', relay => { + setLoadedMessages(true) + }) + + pool.on('event', (relay, subId, ev) => { + console.log('post retrieved from ', relay.url, ev.content) + onMsgRead(ev) + }) + return () => { + // Close pool on component unmount or selected channel changes + console.log('Close existing pool') + pool.close() + } + }, [onMsgRead, selectedChannel, nostrQueries]) + + // Load channels data + useEffect(() => { + const loadChData = async () => { + const loadedChannels = [] + for (let i = 0; i < channels.length; i++) { + const ch = channels[i] + + const exist = loadedChannels.find((val) => { return val === ch }) + if (exist) { continue } + // Fech profile. + let channelData = await appData.nostrQueries.getChannelInfo(ch) + console.log('channelData', channelData) + // Set short id as name + if (!channelData) channelData = { name: ch.slice(0, 8) + '...' + ch.slice(-5) } + loadedChannels.push(channelData) + + // Update profile state + setChannelsData(currentChs => { + const newChs = { ...currentChs } + newChs[ch] = channelData + return newChs + }) + } + } + + // loadMessages() + loadChData() + }, [selectedChannel, appData, channels]) + + // Reset states on change channel + const onChangeChannel = useCallback((ch) => { + setSelectedChannel(ch) + setMessages([]) + setLoadedMessages(false) + }, []) + + return ( + <> + + + + + + + + + + + + ) +} + +export default NostrChat diff --git a/src/components/app-body/nostr-chat/message-input.js b/src/components/app-body/nostr-chat/message-input.js new file mode 100644 index 0000000..893365c --- /dev/null +++ b/src/components/app-body/nostr-chat/message-input.js @@ -0,0 +1,124 @@ +/* + Component for the message input area +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Form, Button, InputGroup } from 'react-bootstrap' +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' + +function MessageInput (props) { + const { appData, selectedChannel } = props + const { bchWalletState, writeRelays } = appData + const [message, setMessage] = useState('') + const [onFetch, setOnFetch] = useState(false) + + // Post on nostr network + const handleSubmit = async (e) => { + e.preventDefault() + + try { + setOnFetch(true) + + const { nostrKeyPair } = bchWalletState + + // Convert private key to binary + const privateKeyBin = hexToBytes(nostrKeyPair.privHex) + + // Relay list + // const psf = 'wss://nostr-relay.psfoundation.info' + + // Generate a post. + const eventTemplate = { + kind: 42, + created_at: Math.floor(Date.now() / 1000), + tags: [['e', selectedChannel, 'root']], + content: message + } + 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) + } + } + + const onChange = (e) => { + setMessage(e.target.value) + } + return ( +
+
+ + + + + +
+
+ ) +} + +export default MessageInput diff --git a/src/components/app-body/nostr-chat/message-item.js b/src/components/app-body/nostr-chat/message-item.js new file mode 100644 index 0000000..fccec0c --- /dev/null +++ b/src/components/app-body/nostr-chat/message-item.js @@ -0,0 +1,97 @@ +/* + Component for displaying a single chat message +*/ + +// Global npm libraries +import React, { useEffect, useRef, useState } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faUser } from '@fortawesome/free-solid-svg-icons' + +function MessageItem (props) { + const { message, profiles } = props + const [profile, setProfile] = useState(null) + + const msgRef = useRef(null) + + // Format timestamp + const formatTime = (timestamp) => { + return new Date(timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + hour12: true + }) + } + + useEffect(() => { + if (!profile && profiles[message.pubkey]) { + setProfile(profiles[message.pubkey]) + } + }, [profiles, message]) + + useEffect(() => { + if (msgRef.current && message.latest) { + msgRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'end' + }) + } + }, [msgRef.current, message]) + + return ( +
+ {/* Message Content */} +
+
+
+ {/* Avatar */} +
+ {profile?.picture + ? ( + {`${profile.name} { + e.target.style.display = 'none' + e.target.nextSibling.style.display = 'block' + }} + /> + ) + : } +
+ {!profile && {message.pubkey}} + {profile && profile.name && {profile.name}} + + {formatTime(message.created_at * 1000)} +
+
+ {message.content} +
+
+
+
+ ) +} + +export default MessageItem diff --git a/src/components/app-body/nostr-chat/message-list.js b/src/components/app-body/nostr-chat/message-list.js new file mode 100644 index 0000000..6a6beda --- /dev/null +++ b/src/components/app-body/nostr-chat/message-list.js @@ -0,0 +1,98 @@ +/* + Component for displaying the list of chat messages +*/ + +// Global npm libraries +import React, { useEffect, useState, useCallback } from 'react' + +// Local libraries +import MessageItem from './message-item' +import DateSeparator from './date-separator' +import { Spinner } from 'react-bootstrap' + +function MessageList (props) { + const { messages, loadedMessages } = props + const [groupedMessages, setGroupedMessages] = useState([]) + + // Group messages by date + const groupMessagesByDate = useCallback((messages) => { + const grouped = {} + messages.forEach((message, i) => { + if (i === messages.length - 1) message.latest = true + const date = new Date(message.created_at * 1000).toDateString() + if (!grouped[date]) { + grouped[date] = [] + } + grouped[date].push(message) + }) + + return grouped + }, []) + + useEffect(() => { + const grouped = groupMessagesByDate(messages) + + setGroupedMessages(grouped) + }, [messages, groupMessagesByDate]) + + return ( + <> + +
+ {loadedMessages && (!messages || messages.length === 0) && ( +
+
+

No messages yet. Start the conversation!

+
+
+ )} + + {loadedMessages && messages && messages.length > 0 && ( +
+ {Object.entries(groupedMessages).map(([date, dateMessages]) => ( +
+ + {dateMessages.map((message, index) => ( + + ))} +
+ ))} +
+ )} + {!loadedMessages && +
+ + +
} + +
+ + ) +} + +export default MessageList diff --git a/src/components/app-body/nostr-chat/sample-data.js b/src/components/app-body/nostr-chat/sample-data.js new file mode 100644 index 0000000..25c6aac --- /dev/null +++ b/src/components/app-body/nostr-chat/sample-data.js @@ -0,0 +1,42 @@ +/* + Sample data +*/ + +export const sampleMessages = [ + { + id: '1', + author: 'Hodlcurator', + content: 'Hey guys\nAnyone interested in learning bitcoin', + timestamp: new Date('2024-08-25T12:01:00').getTime(), + avatar: null + }, + { + id: '2', + author: 'Hodlcurator', + content: 'Ready to learn Bitcoin? Join our free education server, Satscode, and explore the technology, economics, and community behind the future of money. #Bitcoin #Crypto #Education', + timestamp: new Date('2024-08-25T12:02:00').getTime(), + avatar: null + }, + { + id: '3', + author: 'KushBisen', + content: 'I like the UI of IRIS the most. Is there an android client?', + timestamp: new Date('2024-08-25T05:14:00').getTime(), + avatar: null + }, + { + id: '4', + author: 'Flobby25', + content: 'So, what wallets are you using?', + timestamp: new Date('2024-08-25T11:22:00').getTime(), + avatar: null + } +] + +export const sampleChannels = [ + { + id: 'general-chat', + name: 'General Chat', + description: 'This is the general chat' + } +] diff --git a/src/components/nav-menu/index.js b/src/components/nav-menu/index.js index 6df43ee..8d615da 100644 --- a/src/components/nav-menu/index.js +++ b/src/components/nav-menu/index.js @@ -88,6 +88,13 @@ function NavMenu (props) { > Content Creators + + Nostr Chat +
diff --git a/src/config/index.js b/src/config/index.js index 98a881a..b54a9a0 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -23,6 +23,9 @@ const config = { 'wss://nostr-relay.psfoundation.info', 'wss://nos.lol', 'wss://relay.damus.io' + ], + chatsId: [ + '32b0ebc01c984008977fbe476e064946990df75525331b24b37bbba5e89f4039' ] } diff --git a/src/services/nostr-queries.js b/src/services/nostr-queries.js index 69c76e4..7fc3508 100644 --- a/src/services/nostr-queries.js +++ b/src/services/nostr-queries.js @@ -302,4 +302,51 @@ export default class NostrQueries { console.warn(error) } } + + async getChannelInfo (channelId) { + try { + if (this.relays.length === 0) { + return false + } + 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) + }) + + 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) { + return info + } + } + } catch (error) { + console.warn(error) + } + } + + catch (error) { + console.warn(error) + } }