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 */}
+
+
+ )
+}
+
+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
+ ? (
+

{
+ 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)
+ }
}