feat: Add Nostr chat integration for LNBits users
- Introduce a new chat system that allows LNBits users to communicate via Nostr relays. - Implement ChatComponent for real-time messaging, peer selection, and message display. - Create useNostrChat composable to manage Nostr relay connections, message encryption, and user authentication. - Develop ChatPage to serve as the main interface for the chat feature. - Add API endpoints for retrieving current user and public keys for peer messaging. - Ensure secure communication with encryption and admin-only access to private keys.
This commit is contained in:
parent
f4c3f3a0a3
commit
0b62418310
5 changed files with 779 additions and 0 deletions
156
CHAT_INTEGRATION.md
Normal file
156
CHAT_INTEGRATION.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# Nostr Chat Integration for Web-App
|
||||
|
||||
This document describes the Nostr chat integration that allows LNBits users to chat with each other using Nostr relays.
|
||||
|
||||
## Overview
|
||||
|
||||
The chat system integrates with the LNBits user system and Nostr relays to provide encrypted messaging between users. Each user has a Nostr keypair (stored in `pubkey` and `prvkey` fields) that enables secure communication.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. ChatComponent.vue
|
||||
**Location**: `src/components/nostr/ChatComponent.vue`
|
||||
|
||||
A Vue component that provides the chat interface with:
|
||||
- Peer list populated from LNBits users
|
||||
- Real-time messaging using Nostr relays
|
||||
- Encrypted message exchange
|
||||
- Connection status indicators
|
||||
|
||||
### 2. useNostrChat.ts
|
||||
**Location**: `src/composables/useNostrChat.ts`
|
||||
|
||||
A composable that handles:
|
||||
- Nostr relay connections
|
||||
- Message encryption/decryption
|
||||
- User authentication with LNBits
|
||||
- Real-time message subscription
|
||||
|
||||
### 3. ChatPage.vue
|
||||
**Location**: `src/pages/ChatPage.vue`
|
||||
|
||||
A page that integrates the chat component into the web-app.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Get Current User
|
||||
```bash
|
||||
GET /users/api/v1/user/me
|
||||
Authorization: Bearer <admin_token>
|
||||
|
||||
Response:
|
||||
{
|
||||
"id": "user_id",
|
||||
"username": "username",
|
||||
"email": "email@example.com",
|
||||
"pubkey": "nostr_public_key",
|
||||
"prvkey": "nostr_private_key",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Get All User Public Keys
|
||||
```bash
|
||||
GET /users/api/v1/nostr/pubkeys
|
||||
Authorization: Bearer <admin_token>
|
||||
|
||||
Response:
|
||||
[
|
||||
{
|
||||
"user_id": "user_id",
|
||||
"username": "username",
|
||||
"pubkey": "nostr_public_key"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 1. User Integration
|
||||
- Automatically loads peers from LNBits user database
|
||||
- Uses existing `pubkey` and `prvkey` fields
|
||||
- Admin-only access to private keys for messaging
|
||||
|
||||
### 2. Nostr Relay Integration
|
||||
- Connects to multiple Nostr relays for redundancy
|
||||
- Real-time message delivery
|
||||
- Encrypted end-to-end messaging
|
||||
|
||||
### 3. UI Features
|
||||
- Peer list with user avatars and names
|
||||
- Real-time message display
|
||||
- Connection status indicators
|
||||
- Message timestamps
|
||||
- Auto-scroll to latest messages
|
||||
|
||||
## Security
|
||||
|
||||
1. **Encryption**: All messages are encrypted using NIP-04 (Nostr encrypted direct messages)
|
||||
2. **Private Key Access**: Only admin users can access private keys for messaging
|
||||
3. **Relay Security**: Messages are distributed across multiple relays for redundancy
|
||||
4. **User Authentication**: Requires LNBits authentication to access chat
|
||||
|
||||
## Setup Requirements
|
||||
|
||||
1. **NostrTools**: The web-app needs NostrTools loaded globally
|
||||
2. **Admin Access**: Users need admin privileges to access private keys
|
||||
3. **Relay Configuration**: Default relays are configured in the composable
|
||||
4. **LNBits Integration**: Requires the updated LNBits API endpoints
|
||||
|
||||
## Usage
|
||||
|
||||
1. Navigate to `/chat` in the web-app
|
||||
2. The system will automatically load peers from LNBits
|
||||
3. Select a peer to start chatting
|
||||
4. Messages are encrypted and sent via Nostr relays
|
||||
|
||||
## Configuration
|
||||
|
||||
### Default Relays
|
||||
The system connects to these relays by default:
|
||||
- `wss://nostr.atitlan.io`
|
||||
- `wss://relay.damus.io`
|
||||
- `wss://nos.lol`
|
||||
|
||||
### Relay Configuration
|
||||
You can modify the relays in `useNostrChat.ts`:
|
||||
```typescript
|
||||
const DEFAULT_RELAYS: NostrRelayConfig[] = [
|
||||
{ url: 'wss://your-relay.com', read: true, write: true }
|
||||
]
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Message Persistence**: Store messages locally for offline access
|
||||
2. **File Sharing**: Support for encrypted file sharing
|
||||
3. **Group Chats**: Multi-user encrypted conversations
|
||||
4. **Message Search**: Search through conversation history
|
||||
5. **Push Notifications**: Real-time notifications for new messages
|
||||
6. **Profile Integration**: Display user profiles and avatars
|
||||
7. **Message Reactions**: Support for message reactions and replies
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Connection Failed**: Check relay availability and network connectivity
|
||||
2. **Messages Not Sending**: Verify user has admin privileges and private key access
|
||||
3. **Peers Not Loading**: Check LNBits API endpoint and authentication
|
||||
4. **Encryption Errors**: Ensure NostrTools is properly loaded
|
||||
|
||||
### Debug Information
|
||||
|
||||
The chat component logs detailed information to the console:
|
||||
- Connection status
|
||||
- Message encryption/decryption
|
||||
- Relay connection attempts
|
||||
- API call results
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **NostrTools**: For Nostr protocol implementation
|
||||
- **Vue 3**: For reactive UI components
|
||||
- **LNBits API**: For user management and authentication
|
||||
- **Nostr Relays**: For message distribution
|
||||
291
src/components/nostr/ChatComponent.vue
Normal file
291
src/components/nostr/ChatComponent.vue
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h2 class="text-lg font-semibold">Chat</h2>
|
||||
<Badge v-if="isConnected" variant="default" class="text-xs">
|
||||
Connected
|
||||
</Badge>
|
||||
<Badge v-else variant="secondary" class="text-xs">
|
||||
Disconnected
|
||||
</Badge>
|
||||
</div>
|
||||
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
|
||||
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
|
||||
<RefreshCw v-else class="h-4 w-4" />
|
||||
Refresh Peers
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Peer List -->
|
||||
<div class="w-80 border-r bg-muted/30">
|
||||
<div class="p-4 border-b">
|
||||
<h3 class="font-medium">Peers ({{ peers.length }})</h3>
|
||||
</div>
|
||||
<ScrollArea class="h-full">
|
||||
<div class="p-2 space-y-1">
|
||||
<div
|
||||
v-for="peer in peers"
|
||||
:key="peer.user_id"
|
||||
@click="selectPeer(peer)"
|
||||
:class="[
|
||||
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors',
|
||||
selectedPeer?.user_id === peer.user_id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'
|
||||
]"
|
||||
>
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage v-if="getPeerAvatar(peer)" :src="getPeerAvatar(peer)!" />
|
||||
<AvatarFallback>{{ getPeerInitials(peer) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">
|
||||
{{ peer.username || 'Unknown User' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
{{ formatPubkey(peer.pubkey) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div v-if="selectedPeer" class="flex-1 flex flex-col">
|
||||
<!-- Chat Header -->
|
||||
<div class="p-4 border-b">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage v-if="getPeerAvatar(selectedPeer)" :src="getPeerAvatar(selectedPeer)!" />
|
||||
<AvatarFallback>{{ getPeerInitials(selectedPeer) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 class="font-medium">{{ selectedPeer.username || 'Unknown User' }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatPubkey(selectedPeer.pubkey) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<ScrollArea class="flex-1 p-4">
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="message in currentMessages"
|
||||
:key="message.id"
|
||||
:class="[
|
||||
'flex',
|
||||
message.sent ? 'justify-end' : 'justify-start'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'max-w-xs lg:max-w-md px-4 py-2 rounded-lg',
|
||||
message.sent
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted'
|
||||
]"
|
||||
>
|
||||
<p class="text-sm">{{ message.content }}</p>
|
||||
<p class="text-xs opacity-70 mt-1">
|
||||
{{ formatTime(message.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="messagesEndRef" />
|
||||
</ScrollArea>
|
||||
|
||||
<!-- Message Input -->
|
||||
<div class="p-4 border-t">
|
||||
<form @submit.prevent="sendMessage" class="flex space-x-2">
|
||||
<Input
|
||||
v-model="messageInput"
|
||||
placeholder="Type a message..."
|
||||
:disabled="!isConnected || !selectedPeer"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button type="submit" :disabled="!isConnected || !selectedPeer || !messageInput.trim()">
|
||||
<Send class="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Peer Selected -->
|
||||
<div v-else class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<MessageSquare class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium mb-2">No peer selected</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Select a peer from the list to start chatting
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { Send, RefreshCw, MessageSquare } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { useNostrChat } from '@/composables/useNostrChat'
|
||||
|
||||
// Types
|
||||
interface Peer {
|
||||
user_id: string
|
||||
username: string
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
content: string
|
||||
created_at: number
|
||||
sent: boolean
|
||||
}
|
||||
|
||||
// State
|
||||
const peers = ref<Peer[]>([])
|
||||
const selectedPeer = ref<Peer | null>(null)
|
||||
const messageInput = ref('')
|
||||
const messagesEndRef = ref<HTMLDivElement | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Nostr chat composable
|
||||
const {
|
||||
isConnected,
|
||||
messages,
|
||||
sendMessage: sendNostrMessage,
|
||||
connect,
|
||||
disconnect,
|
||||
subscribeToPeer
|
||||
} = useNostrChat()
|
||||
|
||||
// Computed
|
||||
const currentMessages = computed(() => {
|
||||
if (!selectedPeer.value) return []
|
||||
return messages.value.get(selectedPeer.value.pubkey) || []
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadPeers = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await fetch('/users/api/v1/nostr/pubkeys', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('admin_token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load peers')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
peers.value = data.map((peer: any) => ({
|
||||
user_id: peer.user_id,
|
||||
username: peer.username,
|
||||
pubkey: peer.pubkey
|
||||
}))
|
||||
|
||||
console.log(`Loaded ${peers.value.length} peers`)
|
||||
} catch (error) {
|
||||
console.error('Failed to load peers:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshPeers = () => {
|
||||
loadPeers()
|
||||
}
|
||||
|
||||
const selectPeer = async (peer: Peer) => {
|
||||
selectedPeer.value = peer
|
||||
messageInput.value = ''
|
||||
|
||||
// Subscribe to messages from this peer
|
||||
await subscribeToPeer(peer.pubkey)
|
||||
|
||||
// Scroll to bottom after messages load
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!selectedPeer.value || !messageInput.value.trim()) return
|
||||
|
||||
try {
|
||||
await sendNostrMessage(selectedPeer.value.pubkey, messageInput.value)
|
||||
messageInput.value = ''
|
||||
|
||||
// Scroll to bottom
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (messagesEndRef.value) {
|
||||
messagesEndRef.value.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const formatPubkey = (pubkey: string) => {
|
||||
return pubkey.slice(0, 8) + '...' + pubkey.slice(-8)
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getPeerAvatar = (peer: Peer) => {
|
||||
// You can implement avatar logic here
|
||||
return null
|
||||
}
|
||||
|
||||
const getPeerInitials = (peer: Peer) => {
|
||||
if (peer.username) {
|
||||
return peer.username.slice(0, 2).toUpperCase()
|
||||
}
|
||||
return peer.pubkey.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
await connect()
|
||||
await loadPeers()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
// Watch for new messages and scroll to bottom
|
||||
watch(currentMessages, () => {
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
301
src/composables/useNostrChat.ts
Normal file
301
src/composables/useNostrChat.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { ref, computed, readonly } from 'vue'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
|
||||
// Types
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
content: string
|
||||
created_at: number
|
||||
sent: boolean
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export interface NostrRelayConfig {
|
||||
url: string
|
||||
read?: boolean
|
||||
write?: boolean
|
||||
}
|
||||
|
||||
// Default relays - you can configure these
|
||||
const DEFAULT_RELAYS: NostrRelayConfig[] = [
|
||||
{ url: 'wss://nostr.atitlan.io', read: true, write: true },
|
||||
{ url: 'wss://relay.damus.io', read: true, write: true },
|
||||
{ url: 'wss://nos.lol', read: true, write: true }
|
||||
]
|
||||
|
||||
export function useNostrChat() {
|
||||
const nostrStore = useNostrStore()
|
||||
|
||||
// State
|
||||
const isConnected = ref(false)
|
||||
const messages = ref<Map<string, ChatMessage[]>>(new Map())
|
||||
const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null)
|
||||
const connectedRelays = ref<any[]>([])
|
||||
const processedMessageIds = ref(new Set<string>())
|
||||
|
||||
// Computed
|
||||
const isLoggedIn = computed(() => !!currentUser.value)
|
||||
|
||||
// Initialize NostrTools
|
||||
const waitForNostrTools = async (timeout = 5000): Promise<void> => {
|
||||
const start = Date.now()
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
if (window.NostrTools) {
|
||||
return
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
throw new Error('NostrTools failed to load within timeout period')
|
||||
}
|
||||
|
||||
// Connect to relays
|
||||
const connectToRelay = async (url: string): Promise<any> => {
|
||||
try {
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
await relay.connect()
|
||||
console.log(`Connected to relay: ${url}`)
|
||||
return relay
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to ${url}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to all relays
|
||||
const connect = async () => {
|
||||
try {
|
||||
await waitForNostrTools()
|
||||
|
||||
// Get current user from LNBits
|
||||
await loadCurrentUser()
|
||||
|
||||
if (!currentUser.value) {
|
||||
throw new Error('No user logged in')
|
||||
}
|
||||
|
||||
// Connect to relays
|
||||
const relays = await Promise.all(
|
||||
DEFAULT_RELAYS.map(relay => connectToRelay(relay.url))
|
||||
)
|
||||
|
||||
connectedRelays.value = relays.filter(relay => relay !== null)
|
||||
isConnected.value = true
|
||||
|
||||
console.log(`Connected to ${connectedRelays.value.length} relays`)
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from relays
|
||||
const disconnect = () => {
|
||||
connectedRelays.value.forEach(relay => {
|
||||
try {
|
||||
relay.close()
|
||||
} catch (error) {
|
||||
console.error('Error closing relay:', error)
|
||||
}
|
||||
})
|
||||
|
||||
connectedRelays.value = []
|
||||
isConnected.value = false
|
||||
messages.value.clear()
|
||||
processedMessageIds.value.clear()
|
||||
}
|
||||
|
||||
// Load current user from LNBits
|
||||
const loadCurrentUser = async () => {
|
||||
try {
|
||||
// Get current user from LNBits API
|
||||
const response = await fetch('/users/api/v1/user/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('admin_token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.json()
|
||||
currentUser.value = {
|
||||
pubkey: user.pubkey,
|
||||
prvkey: user.prvkey // This should be available if user is admin
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to load current user')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load current user:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to messages from a specific peer
|
||||
const subscribeToPeer = async (peerPubkey: string) => {
|
||||
if (!currentUser.value || !isConnected.value) {
|
||||
throw new Error('Not connected')
|
||||
}
|
||||
|
||||
const myPubkey = currentUser.value.pubkey
|
||||
|
||||
// Subscribe to direct messages (kind 4)
|
||||
connectedRelays.value.forEach(relay => {
|
||||
const sub = relay.sub([
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [peerPubkey],
|
||||
'#p': [myPubkey]
|
||||
},
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [myPubkey],
|
||||
'#p': [peerPubkey]
|
||||
}
|
||||
])
|
||||
|
||||
sub.on('event', (event: any) => {
|
||||
handleIncomingMessage(event, peerPubkey)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Handle incoming message
|
||||
const handleIncomingMessage = async (event: any, peerPubkey: string) => {
|
||||
if (processedMessageIds.value.has(event.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
processedMessageIds.value.add(event.id)
|
||||
|
||||
try {
|
||||
// Decrypt the message
|
||||
const decryptedContent = await window.NostrTools.nip04.decrypt(
|
||||
currentUser.value!.prvkey,
|
||||
event.pubkey,
|
||||
event.content
|
||||
)
|
||||
|
||||
const message: ChatMessage = {
|
||||
id: event.id,
|
||||
content: decryptedContent,
|
||||
created_at: event.created_at,
|
||||
sent: event.pubkey === currentUser.value!.pubkey,
|
||||
pubkey: event.pubkey
|
||||
}
|
||||
|
||||
// Add message to the appropriate conversation
|
||||
const conversationKey = event.pubkey === currentUser.value!.pubkey
|
||||
? peerPubkey
|
||||
: event.pubkey
|
||||
|
||||
if (!messages.value.has(conversationKey)) {
|
||||
messages.value.set(conversationKey, [])
|
||||
}
|
||||
|
||||
messages.value.get(conversationKey)!.push(message)
|
||||
|
||||
// Sort messages by timestamp
|
||||
messages.value.get(conversationKey)!.sort((a, b) => a.created_at - b.created_at)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Send message to a peer
|
||||
const sendMessage = async (peerPubkey: string, content: string) => {
|
||||
if (!currentUser.value || !isConnected.value) {
|
||||
throw new Error('Not connected')
|
||||
}
|
||||
|
||||
try {
|
||||
// Encrypt the message
|
||||
const encryptedContent = await window.NostrTools.nip04.encrypt(
|
||||
currentUser.value.prvkey,
|
||||
peerPubkey,
|
||||
content
|
||||
)
|
||||
|
||||
// Create the event
|
||||
const event = {
|
||||
kind: 4,
|
||||
pubkey: currentUser.value.pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', peerPubkey]],
|
||||
content: encryptedContent
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
event.id = window.NostrTools.getEventHash(event)
|
||||
event.sig = window.NostrTools.getSignature(event, currentUser.value.prvkey)
|
||||
|
||||
// Publish to relays
|
||||
const publishPromises = connectedRelays.value.map(relay => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const pub = relay.publish(event)
|
||||
|
||||
pub.on('ok', () => {
|
||||
console.log('Message published successfully')
|
||||
resolve()
|
||||
})
|
||||
|
||||
pub.on('failed', (reason: string) => {
|
||||
console.error('Failed to publish message:', reason)
|
||||
reject(new Error(reason))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await Promise.all(publishPromises)
|
||||
|
||||
// Add message to local state
|
||||
const message: ChatMessage = {
|
||||
id: event.id,
|
||||
content,
|
||||
created_at: event.created_at,
|
||||
sent: true,
|
||||
pubkey: currentUser.value.pubkey
|
||||
}
|
||||
|
||||
if (!messages.value.has(peerPubkey)) {
|
||||
messages.value.set(peerPubkey, [])
|
||||
}
|
||||
|
||||
messages.value.get(peerPubkey)!.push(message)
|
||||
|
||||
// Sort messages by timestamp
|
||||
messages.value.get(peerPubkey)!.sort((a, b) => a.created_at - b.created_at)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Get messages for a specific peer
|
||||
const getMessages = (peerPubkey: string): ChatMessage[] => {
|
||||
return messages.value.get(peerPubkey) || []
|
||||
}
|
||||
|
||||
// Clear messages for a specific peer
|
||||
const clearMessages = (peerPubkey: string) => {
|
||||
messages.value.delete(peerPubkey)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isConnected: readonly(isConnected),
|
||||
messages: readonly(messages),
|
||||
isLoggedIn: readonly(isLoggedIn),
|
||||
|
||||
// Methods
|
||||
connect,
|
||||
disconnect,
|
||||
subscribeToPeer,
|
||||
sendMessage,
|
||||
getMessages,
|
||||
clearMessages,
|
||||
loadCurrentUser
|
||||
}
|
||||
}
|
||||
22
src/pages/ChatPage.vue
Normal file
22
src/pages/ChatPage.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<div class="container mx-auto p-4 h-screen">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl font-bold">Nostr Chat</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Chat with other LNBits users using Nostr relays
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Chat Component -->
|
||||
<div class="flex-1 border rounded-lg overflow-hidden">
|
||||
<ChatComponent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChatComponent from '@/components/nostr/ChatComponent.vue'
|
||||
</script>
|
||||
|
|
@ -49,6 +49,15 @@ const router = createRouter({
|
|||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
component: () => import('@/pages/ChatPage.vue'),
|
||||
meta: {
|
||||
title: 'Nostr Chat',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
|
||||
]
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue