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 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) {
|
||||
<Route path='/content-creators' element={<ContentCreators appData={appData} />} />
|
||||
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
|
||||
<Route path='/offers' element={<Offers appData={appData} />} />
|
||||
<Route path='/nostr-chat' element={<NostrChat appData={appData} />} />
|
||||
</Routes>
|
||||
{/** Show in all paths except the servers view */}
|
||||
{/* {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
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={currentPath === '/nostr-chat' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
to='/nostr-chat'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Nostr Chat
|
||||
</NavLink>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ const config = {
|
||||
'wss://nostr-relay.psfoundation.info',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.damus.io'
|
||||
],
|
||||
chatsId: [
|
||||
'32b0ebc01c984008977fbe476e064946990df75525331b24b37bbba5e89f4039'
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user