mirror of
https://github.com/Permissionless-Software-Foundation/bch-dex-taker-v2.git
synced 2026-09-21 16:52:01 -07:00
feat(nostr): Created nostr general chat interface
This commit is contained in:
@@ -29,6 +29,7 @@ import Feeds from './nostr/feeds/index.js'
|
|||||||
import ContentCreators from './nostr/content-creators/index.js'
|
import ContentCreators from './nostr/content-creators/index.js'
|
||||||
import UserDataReview from './user-data-review'
|
import UserDataReview from './user-data-review'
|
||||||
import Offers from './offers'
|
import Offers from './offers'
|
||||||
|
import NostrChat from './nostr-chat'
|
||||||
function AppBody (props) {
|
function AppBody (props) {
|
||||||
// Dependency injection through props
|
// Dependency injection through props
|
||||||
const appData = props.appData
|
const appData = props.appData
|
||||||
@@ -54,6 +55,7 @@ function AppBody (props) {
|
|||||||
<Route path='/content-creators' element={<ContentCreators appData={appData} />} />
|
<Route path='/content-creators' element={<ContentCreators appData={appData} />} />
|
||||||
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
|
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
|
||||||
<Route path='/offers' element={<Offers appData={appData} />} />
|
<Route path='/offers' element={<Offers appData={appData} />} />
|
||||||
|
<Route path='/nostr-chat' element={<NostrChat appData={appData} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
{/** Show in all paths except the servers view */}
|
{/** Show in all paths except the servers view */}
|
||||||
{/* {appData.currentPath !== '/servers' && <SelectServerButton linkTo='/servers' appData={appData} />} */}
|
{/* {appData.currentPath !== '/servers' && <SelectServerButton linkTo='/servers' appData={appData} />} */}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<ListGroup.Item
|
||||||
|
onClick={() => { 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 &&
|
||||||
|
<div className='d-flex align-items-center'>
|
||||||
|
<span
|
||||||
|
className='fw-medium'
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: selectedChannel === channel ? '#444444' : '#495057',
|
||||||
|
fontWeight: selectedChannel === channel ? '600' : '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chInfo?.name || getShortName(channel)}
|
||||||
|
</span>
|
||||||
|
</div>}
|
||||||
|
{!chInfo?.name &&
|
||||||
|
<div className='d-flex align-items-center'>
|
||||||
|
{getShortName(channel)} <Spinner animation='border' size='sm' style={{ marginLeft: '5px' }} />
|
||||||
|
</div>}
|
||||||
|
</ListGroup.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className='text-muted small' style={{ fontStyle: 'italic' }}>
|
||||||
|
No channels available
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListGroup variant='flush' className='bg-transparent'>
|
||||||
|
{channels.map((channel, i) => (
|
||||||
|
<ChannelItem key={`channel${i}`} channel={channel} selectedChannel={selectedChannel} {...props} />
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChannelList
|
||||||
@@ -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 (
|
||||||
|
<div className='p-3 d-flex justify-content-between align-items-center' style={{ backgroundColor: '#ffffff' }}>
|
||||||
|
{chInfo &&
|
||||||
|
<div className='flex-grow-1'>
|
||||||
|
<h5 className='mb-1 text-dark'>{chInfo?.name || getShortName(selectedChannel)}</h5>
|
||||||
|
<small className='text-muted'>{chInfo?.about || ''}</small>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{!chInfo &&
|
||||||
|
<div className='flex-grow-1 ps-4'>
|
||||||
|
<Spinner animation='border' size='sm' />
|
||||||
|
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatHeader
|
||||||
@@ -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 && (
|
||||||
|
<div className='h-100 d-flex align-items-center justify-content-center' style={{ backgroundColor: '#f8f9fa' }}>
|
||||||
|
<div className='text-center text-muted'>
|
||||||
|
<h4>Select a channel to start chatting</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedChannel && (
|
||||||
|
<div className='h-100 d-flex flex-column' style={{ backgroundColor: '#f8f9fa' }}>
|
||||||
|
{/* Chat Header */}
|
||||||
|
<div>
|
||||||
|
<ChatHeader selectedChannel={selectedChannel} {...props} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages Area */}
|
||||||
|
<div className='flex-grow-1 overflow-auto'>
|
||||||
|
<MessageList messages={messages} {...props} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Input */}
|
||||||
|
<div className='border-top border-tertiary'>
|
||||||
|
<MessageInput {...props} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatMain
|
||||||
@@ -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 (
|
||||||
|
<div className='h-100 d-flex flex-column' style={{ backgroundColor: '#ffffff', borderRight: '1px solid #e9ecef' }}>
|
||||||
|
{/* Channels Section */}
|
||||||
|
<div className='flex-grow-1 d-flex flex-column'>
|
||||||
|
<div className='p-4 flex-grow-1'>
|
||||||
|
<div className='d-flex justify-content-between align-items-center mb-3'>
|
||||||
|
<h5 className='mb-0 text-dark fw-semibold' style={{ fontSize: '16px', letterSpacing: '0.5px' }}>
|
||||||
|
Channels
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<ChannelList
|
||||||
|
channels={channels}
|
||||||
|
selectedChannel={selectedChannel}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatSidebar
|
||||||
@@ -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 (
|
||||||
|
<div className='text-center my-4'>
|
||||||
|
<div
|
||||||
|
className='d-inline-block px-3 py-1 rounded-pill'
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#e9ecef',
|
||||||
|
color: '#6c757d',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDate(date)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DateSeparator
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Container fluid className='h-100 p-0 mb-5 '>
|
||||||
|
<Row className='h-100 g-0'>
|
||||||
|
<Col xs={12} md={4} lg={3} className='h-100'>
|
||||||
|
<ChatSidebar
|
||||||
|
channels={channels}
|
||||||
|
selectedChannel={selectedChannel}
|
||||||
|
profiles={profiles}
|
||||||
|
channelsData={channelsData}
|
||||||
|
onChangeChannel={onChangeChannel}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={8} lg={9} className='h-100 pe-2'>
|
||||||
|
<ChatMain
|
||||||
|
loadedMessages={loadedMessages}
|
||||||
|
selectedChannel={selectedChannel}
|
||||||
|
messages={messages}
|
||||||
|
profiles={profiles}
|
||||||
|
channelsData={channelsData}
|
||||||
|
onChangeChannel={onChangeChannel}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NostrChat
|
||||||
@@ -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 (
|
||||||
|
<div className='p-3' style={{ backgroundColor: '#ffffff' }}>
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<InputGroup>
|
||||||
|
<Form.Control
|
||||||
|
as='textarea'
|
||||||
|
rows={3}
|
||||||
|
placeholder='Type a message...'
|
||||||
|
className='border-1 bg-transparent text-dark'
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
border: '4px solid rgb(235, 232, 232)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
resize: 'none',
|
||||||
|
minHeight: '60px',
|
||||||
|
maxHeight: '120px'
|
||||||
|
}}
|
||||||
|
value={message}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={onFetch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
variant='link'
|
||||||
|
className='text-dark border-0 bg-transparent ms-2'
|
||||||
|
style={{
|
||||||
|
color: '#6c757d',
|
||||||
|
transition: 'color 0.2s ease',
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}
|
||||||
|
title='Send message'
|
||||||
|
disabled={onFetch}
|
||||||
|
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPaperPlane} />
|
||||||
|
</Button>
|
||||||
|
</InputGroup>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MessageInput
|
||||||
@@ -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 (
|
||||||
|
<div className='mb-3 d-flex align-items-start' ref={msgRef}>
|
||||||
|
{/* Message Content */}
|
||||||
|
<div className='flex-grow-1'>
|
||||||
|
<div
|
||||||
|
className='text-dark p-3 rounded-3'
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
border: '1px solid #e9ecef',
|
||||||
|
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||||
|
maxWidth: '85%',
|
||||||
|
wordWrap: 'break-word'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='d-flex align-items-center mb-2'>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className='me-2 flex-shrink-0'>
|
||||||
|
{profile?.picture
|
||||||
|
? (
|
||||||
|
<img
|
||||||
|
src={profile.picture}
|
||||||
|
alt={`${profile.name} Avatar`}
|
||||||
|
className='rounded-circle'
|
||||||
|
style={{ width: '24px', height: '24px' }}
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.style.display = 'none'
|
||||||
|
e.target.nextSibling.style.display = 'block'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: <FontAwesomeIcon
|
||||||
|
icon={faUser}
|
||||||
|
className='rounded-circle d-flex align-items-center justify-content-center'
|
||||||
|
style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
backgroundColor: '#e9ecef',
|
||||||
|
color: '#6c757d',
|
||||||
|
fontSize: '12px',
|
||||||
|
display: message.avatar ? 'none' : 'flex'
|
||||||
|
}}
|
||||||
|
/>}
|
||||||
|
</div>
|
||||||
|
{!profile && <span className='fw-bold text-dark me-2'>{message.pubkey}</span>}
|
||||||
|
{profile && profile.name && <span className='fw-bold text-dark me-2'>{profile.name}</span>}
|
||||||
|
|
||||||
|
<small className='text-muted'>{formatTime(message.created_at * 1000)}</small>
|
||||||
|
</div>
|
||||||
|
<div style={{ lineHeight: '1.4', wordBreak: 'break-all' }}>
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MessageItem
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 0 !important;
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.hide-scrollbar {
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
<div
|
||||||
|
className='h-100 overflow-auto hide-scrollbar'
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
height: 'calc(100vh - 200px)',
|
||||||
|
maxHeight: 'calc(100vh - 200px)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadedMessages && (!messages || messages.length === 0) && (
|
||||||
|
<div className='h-100 d-flex align-items-center justify-content-center'>
|
||||||
|
<div className='text-center text-muted'>
|
||||||
|
<p>No messages yet. Start the conversation!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedMessages && messages && messages.length > 0 && (
|
||||||
|
<div className='p-3' style={{ overflowY: 'auto', maxHeight: '50vh' }}>
|
||||||
|
{Object.entries(groupedMessages).map(([date, dateMessages]) => (
|
||||||
|
<div key={date}>
|
||||||
|
<DateSeparator date={date} />
|
||||||
|
{dateMessages.map((message, index) => (
|
||||||
|
<MessageItem
|
||||||
|
key={`${message.id}-${index}`}
|
||||||
|
message={message}
|
||||||
|
totalMessages={messages.length}
|
||||||
|
messageIndex={index}
|
||||||
|
{...props}
|
||||||
|
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadedMessages &&
|
||||||
|
<div className='p-3' style={{ minHeight: '50vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<Spinner animation='border' size='md' />
|
||||||
|
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MessageList
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -88,6 +88,13 @@ function NavMenu (props) {
|
|||||||
>
|
>
|
||||||
Content Creators
|
Content Creators
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
className={currentPath === '/nostr-chat' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||||
|
to='/nostr-chat'
|
||||||
|
onClick={handleClickEvent}
|
||||||
|
>
|
||||||
|
Nostr Chat
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const config = {
|
|||||||
'wss://nostr-relay.psfoundation.info',
|
'wss://nostr-relay.psfoundation.info',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
'wss://relay.damus.io'
|
'wss://relay.damus.io'
|
||||||
|
],
|
||||||
|
chatsId: [
|
||||||
|
'32b0ebc01c984008977fbe476e064946990df75525331b24b37bbba5e89f4039'
|
||||||
]
|
]
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,4 +302,51 @@ export default class NostrQueries {
|
|||||||
console.warn(error)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user