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
|
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