import { ref, computed, readonly } from 'vue' import { nip04, finalizeEvent, type EventTemplate } from 'nostr-tools' import { hexToBytes } from '@/lib/utils/crypto' import { getAuthToken } from '@/lib/config/lnbits' import { config } from '@/lib/config' import { useRelayHub } from './useRelayHub' import { useAuth } from './useAuth' // Types export interface ChatMessage { id: string content: string created_at: number sent: boolean pubkey: string } export interface NostrRelayConfig { url: string read?: boolean write?: boolean } // Add notification system for unread messages interface UnreadMessageData { lastReadTimestamp: number unreadCount: number processedMessageIds: Set // Track which messages we've already counted as unread } const UNREAD_MESSAGES_KEY = 'nostr-chat-unread-messages' // Get unread message data for a peer const getUnreadData = (peerPubkey: string): UnreadMessageData => { try { const stored = localStorage.getItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`) if (stored) { const data = JSON.parse(stored) // Convert the array back to a Set for processedMessageIds return { ...data, processedMessageIds: new Set(data.processedMessageIds || []) } } return { lastReadTimestamp: 0, unreadCount: 0, processedMessageIds: new Set() } } catch (error) { console.warn('Failed to load unread data for peer:', peerPubkey, error) return { lastReadTimestamp: 0, unreadCount: 0, processedMessageIds: new Set() } } } // Save unread message data for a peer const saveUnreadData = (peerPubkey: string, data: UnreadMessageData): void => { try { // Convert Set to array for localStorage serialization const serializableData = { ...data, processedMessageIds: Array.from(data.processedMessageIds) } localStorage.setItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`, JSON.stringify(serializableData)) } catch (error) { console.warn('Failed to save unread data for peer:', peerPubkey, error) } } export function useNostrChat() { // Use the centralized relay hub const relayHub = useRelayHub() // Use the main authentication system const auth = useAuth() // State const messages = ref>(new Map()) const processedMessageIds = ref(new Set()) const onMessageAdded = ref<((peerPubkey: string) => void) | null>(null) // Reactive unread counts const unreadCounts = ref>(new Map()) // Track latest message timestamp for each peer (for sorting) const latestMessageTimestamps = ref>(new Map()) // Store peers globally const peers = ref([]) // Computed - use relay hub's connection status and auth system const isConnected = computed(() => relayHub.isConnected.value) // Get current user from auth system const currentUser = computed(() => { const user = auth.currentUser.value if (!user) { return null } // Check if the user has a pubkey field if (!user.pubkey) { return null } // Check if the user has a prvkey field if (!user.prvkey) { return null } // Use the actual user data - assume prvkey and pubkey contain real Nostr keys return { pubkey: user.pubkey, prvkey: user.prvkey } }) // Check if user is authenticated (has LNBits login) const isAuthenticated = computed(() => { return auth.currentUser.value !== null }) // Check if user has complete Nostr keypair const hasNostrKeys = computed(() => { const user = currentUser.value return user && user.pubkey && user.prvkey }) // Get Nostr key status for debugging const getNostrKeyStatus = () => { const user = auth.currentUser.value if (!user) { return { hasUser: false, hasPubkey: false, hasPrvkey: false, message: 'No user logged in' } } return { hasUser: true, hasPubkey: !!user.pubkey, hasPrvkey: !!user.prvkey, message: user.pubkey && user.prvkey ? 'User has complete Nostr keypair' : 'User missing Nostr keys', pubkey: user.pubkey } } // Get unread count for a peer const getUnreadCount = (peerPubkey: string): number => { return unreadCounts.value.get(peerPubkey) || 0 } // Get all unread counts const getAllUnreadCounts = (): Map => { return new Map(unreadCounts.value) } // Get total unread count across all peers const getTotalUnreadCount = (): number => { let total = 0 for (const count of unreadCounts.value.values()) { total += count } return total } // Get latest message timestamp for a peer const getLatestMessageTimestamp = (peerPubkey: string): number => { return latestMessageTimestamps.value.get(peerPubkey) || 0 } // Get all latest message timestamps const getAllLatestMessageTimestamps = (): Map => { return new Map(latestMessageTimestamps.value) } // Update latest message timestamp for a peer const updateLatestMessageTimestamp = (peerPubkey: string, timestamp: number): void => { const current = latestMessageTimestamps.value.get(peerPubkey) || 0 if (timestamp > current) { latestMessageTimestamps.value.set(peerPubkey, timestamp) } } // Update unread count for a peer const updateUnreadCount = (peerPubkey: string, count: number): void => { if (count > 0) { unreadCounts.value.set(peerPubkey, count) } else { unreadCounts.value.delete(peerPubkey) } // Force reactivity unreadCounts.value = new Map(unreadCounts.value) // Save to localStorage const unreadData = getUnreadData(peerPubkey) unreadData.unreadCount = count saveUnreadData(peerPubkey, unreadData) } // Mark messages as read for a peer const markMessagesAsRead = (peerPubkey: string): void => { const currentTimestamp = Math.floor(Date.now() / 1000) // Update last read timestamp, reset unread count, and clear processed message IDs const updatedData: UnreadMessageData = { lastReadTimestamp: currentTimestamp, unreadCount: 0, processedMessageIds: new Set() // Clear processed messages when marking as read } saveUnreadData(peerPubkey, updatedData) updateUnreadCount(peerPubkey, 0) // Also clear any processed message IDs from the global set that might be from this peer // This helps prevent duplicate message issues } // Load unread counts from localStorage const loadUnreadCounts = (): void => { try { const keys = Object.keys(localStorage).filter(key => key.startsWith(`${UNREAD_MESSAGES_KEY}-`) ) for (const key of keys) { const peerPubkey = key.replace(`${UNREAD_MESSAGES_KEY}-`, '') const unreadData = getUnreadData(peerPubkey) // Recalculate unread count based on actual messages and lastReadTimestamp const peerMessages = messages.value.get(peerPubkey) || [] let actualUnreadCount = 0 for (const message of peerMessages) { // Only count messages not sent by us and created after last read timestamp if (!message.sent && message.created_at > unreadData.lastReadTimestamp) { actualUnreadCount++ } } // Update the stored count to match reality if (actualUnreadCount !== unreadData.unreadCount) { unreadData.unreadCount = actualUnreadCount saveUnreadData(peerPubkey, unreadData) } if (actualUnreadCount > 0) { unreadCounts.value.set(peerPubkey, actualUnreadCount) } } } catch (error) { console.warn('Failed to load unread counts:', error) } } // Initialize unread counts on startup loadUnreadCounts() // Clear unread count for a peer // const clearUnreadCount = (peerPubkey: string): void => { // unreadCounts.value.delete(peerPubkey) // // // Clear from localStorage // const unreadData = getUnreadData(peerPubkey) // unreadData.unreadCount = 0 // saveUnreadData(peerPubkey, unreadData) // } // Clear all unread counts const clearAllUnreadCounts = (): void => { unreadCounts.value.clear() // Clear from localStorage for all peers for (const [peerPubkey] of messages.value) { const unreadData = getUnreadData(peerPubkey) unreadData.unreadCount = 0 saveUnreadData(peerPubkey, unreadData) } // Also clear from localStorage for all stored keys try { const keys = Object.keys(localStorage).filter(key => key.startsWith(`${UNREAD_MESSAGES_KEY}-`) ) for (const key of keys) { const peerPubkey = key.replace(`${UNREAD_MESSAGES_KEY}-`, '') const unreadData = getUnreadData(peerPubkey) unreadData.unreadCount = 0 saveUnreadData(peerPubkey, unreadData) } } catch (error) { console.warn('Failed to clear unread counts from localStorage:', error) } } // Clear processed message IDs for a peer const clearProcessedMessageIds = (peerPubkey: string): void => { const unreadData = getUnreadData(peerPubkey) unreadData.processedMessageIds.clear() saveUnreadData(peerPubkey, unreadData) } // Debug unread data for a peer const debugUnreadData = (peerPubkey: string): void => { // Function kept for potential future debugging getUnreadData(peerPubkey) } // Get relay configuration const getRelays = (): NostrRelayConfig[] => { return config.nostr.relays.map(url => ({ url, read: true, write: true })) } // Connect using the relay hub const connect = async () => { try { // The relay hub should already be initialized by the app if (!relayHub.isConnected.value) { await relayHub.connect() } } catch (error) { console.error('Failed to connect to relays:', error) throw error } } // Disconnect using the relay hub const disconnect = () => { // Note: We don't disconnect the relay hub here as other components might be using it // The relay hub will be managed at the app level } // Load current user from LNBits // const loadCurrentUser = async () => { // try { // // Get current user from LNBits API using the auth endpoint // const authToken = getAuthToken() // if (!authToken) { // throw new Error('No authentication token found') // } // const API_BASE_URL = config.api.baseUrl || 'http://localhost:5006' // const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/me`, { // headers: { // 'Authorization': `Bearer ${authToken}`, // 'Content-Type': 'application/json' // } // }) // console.log('API Response status:', response.status) // console.log('API Response headers:', response.headers) // const responseText = await response.text() // console.log('API Response text:', responseText) // if (response.ok) { // try { // const user = JSON.parse(responseText) // currentUser.value = { // pubkey: user.pubkey, // prvkey: user.prvkey // } // } catch (parseError) { // console.error('JSON Parse Error:', parseError) // console.error('Response was:', responseText) // throw new Error('Invalid JSON response from API') // } // } else { // console.error('API Error:', response.status, responseText) // throw new Error(`Failed to load current user: ${response.status}`) // } // } catch (error) { // console.error('Failed to load current user:', error) // throw error // } // } // Subscribe to a specific peer for messages const subscribeToPeer = async (peerPubkey: string) => { if (!currentUser.value) { console.warn('Cannot subscribe to peer: no user logged in') return } if (!currentUser.value.pubkey) { console.warn('Cannot subscribe to peer: no public key available') return } // Check if we have a pool and are connected if (!relayHub.isConnected.value) { console.warn('Not connected to relays - attempting to connect...') await connect() } if (!relayHub.isConnected.value) { throw new Error('Failed to initialize Nostr pool') } try { // Subscribe to direct messages (kind 4) from this peer const filter = { kinds: [4], '#p': [currentUser.value.pubkey], // Messages where we are the recipient authors: [peerPubkey] // Messages from this specific peer } // Use the relay hub to subscribe const unsubscribe = relayHub.subscribe({ id: `peer-${peerPubkey}`, filters: [filter], onEvent: (event) => { handleIncomingMessage(event, peerPubkey) } }) return unsubscribe } catch (error) { console.error('Failed to subscribe to peer:', error) throw error } } // Subscribe to a peer for notifications only (without loading full message history) const subscribeToPeerForNotifications = async (peerPubkey: string) => { if (!currentUser.value) { console.warn('No user logged in - cannot subscribe to peer notifications') return null } // Check if we have a pool and are connected if (!relayHub.isConnected.value) { console.warn('Not connected to relays - attempting to connect...') await connect() } if (!relayHub.isConnected.value) { throw new Error('Failed to initialize Nostr pool') } const myPubkey = currentUser.value.pubkey // Subscribe to new messages only (no historical messages) const relayConfigs = getRelays() const filters = [ { kinds: [4], authors: [peerPubkey], '#p': [myPubkey] }, { kinds: [4], authors: [myPubkey], '#p': [peerPubkey] } ] const unsubscribe = relayHub.subscribe({ id: `notifications-${peerPubkey}-${Date.now()}`, filters, relays: relayConfigs.map(r => r.url), onEvent: (event: any) => { handleIncomingMessage(event, peerPubkey) }, onEose: () => { // Notification subscription closed } }) return unsubscribe } // Handle incoming message const handleIncomingMessage = async (event: any, peerPubkey: string) => { if (!currentUser.value || !currentUser.value.prvkey) { console.warn('Cannot decrypt message: no private key available') return } // Check if we've already processed this message to prevent duplicates if (processedMessageIds.value.has(event.id)) { return } try { // For NIP-04 direct messages, always use peerPubkey as the second argument // This is the public key of the other party in the conversation const isSentByMe = event.pubkey === currentUser.value.pubkey const decryptedContent = await nip04.decrypt( currentUser.value.prvkey, peerPubkey, // Always use peerPubkey for shared secret derivation event.content ) // Create chat message const message: ChatMessage = { id: event.id, content: decryptedContent, created_at: event.created_at, sent: isSentByMe, pubkey: event.pubkey } // Add to messages if (!messages.value.has(peerPubkey)) { messages.value.set(peerPubkey, []) } messages.value.get(peerPubkey)!.push(message) // Mark as unread if not sent by us AND created after last read timestamp if (!isSentByMe) { const unreadData = getUnreadData(peerPubkey) // Only count as unread if message was created after last read timestamp if (event.created_at > unreadData.lastReadTimestamp) { // Increment the unread count for this peer const currentCount = unreadCounts.value.get(peerPubkey) || 0 const newCount = currentCount + 1 unreadCounts.value.set(peerPubkey, newCount) // Force reactivity unreadCounts.value = new Map(unreadCounts.value) // Save to localStorage unreadData.unreadCount = newCount saveUnreadData(peerPubkey, unreadData) } } // Update latest message timestamp updateLatestMessageTimestamp(peerPubkey, event.created_at) // Mark this message as processed to prevent duplicates processedMessageIds.value.add(event.id) // Trigger callback if set if (onMessageAdded.value) { onMessageAdded.value(peerPubkey) } } catch (error) { console.error('Failed to decrypt message:', error) } } // Send message to a peer const sendMessage = async (peerPubkey: string, content: string) => { if (!currentUser.value) { throw new Error('No user logged in - please authenticate first') } // Check if we have the required Nostr keypair if (!currentUser.value.prvkey) { throw new Error('Nostr private key not available. Please ensure your LNBits account has Nostr keys configured.') } // Check if we have a pool and are connected if (!relayHub.isConnected.value) { console.warn('Not connected to relays - attempting to connect...') await connect() } if (!relayHub.isConnected.value) { throw new Error('Failed to initialize Nostr pool') } try { // Validate keys before encryption if (!currentUser.value.prvkey || !peerPubkey) { throw new Error('Missing private key or peer public key') } // Ensure keys are in correct hex format (64 characters for private key, 64 characters for public key) const privateKey = currentUser.value.prvkey.startsWith('0x') ? currentUser.value.prvkey.slice(2) : currentUser.value.prvkey const publicKey = peerPubkey.startsWith('0x') ? peerPubkey.slice(2) : peerPubkey if (privateKey.length !== 64) { throw new Error(`Invalid private key length: ${privateKey.length} (expected 64)`) } if (publicKey.length !== 64) { throw new Error(`Invalid public key length: ${publicKey.length} (expected 64)`) } // Validate hex format const hexRegex = /^[0-9a-fA-F]+$/ if (!hexRegex.test(privateKey)) { throw new Error(`Invalid private key format: contains non-hex characters`) } if (!hexRegex.test(publicKey)) { throw new Error(`Invalid public key format: contains non-hex characters`) } // Encrypt the message let encryptedContent: string try { encryptedContent = await nip04.encrypt( privateKey, publicKey, content ) } catch (encryptError) { console.error('Encryption failed:', encryptError) throw new Error(`Encryption failed: ${encryptError instanceof Error ? encryptError.message : String(encryptError)}`) } // Create the event template const eventTemplate: EventTemplate = { kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', peerPubkey]], content: encryptedContent } // Finalize the event (sign it) const event = finalizeEvent(eventTemplate, hexToBytes(privateKey)) // Publish to relays using the relay hub await relayHub.publishEvent(event) // Add message to local state const message: ChatMessage = { id: event.id, content, created_at: event.created_at, sent: true, pubkey: currentUser.value.pubkey } // Add to processed IDs to prevent duplicate processing processedMessageIds.value.add(event.id) 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) // Force reactivity by triggering a change messages.value = new Map(messages.value) // Update latest message timestamp for this peer (for sorting) updateLatestMessageTimestamp(peerPubkey, message.created_at) // Trigger callback if set if (onMessageAdded.value) { onMessageAdded.value(peerPubkey) } } 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) } // Load peers from API const loadPeers = async () => { try { const authToken = getAuthToken() if (!authToken) { throw new Error('No authentication token found') } const API_BASE_URL = config.api.baseUrl || 'http://localhost:5006' const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/pubkeys`, { headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' } }) if (!response.ok) { throw new Error(`Failed to load peers: ${response.status}`) } const data = await response.json() const loadedPeers = data.map((peer: any) => ({ user_id: peer.user_id, username: peer.username, pubkey: peer.pubkey })) // Store peers in the singleton state peers.value = loadedPeers return loadedPeers } catch (error) { console.error('Failed to load peers:', error) throw error } } // Subscribe to all peers for notifications (without loading full message history) const subscribeToAllPeersForNotifications = async (peers: any[]) => { if (!peers.length) { return } // Wait for connection to be established if (!relayHub.isConnected.value) { // Wait a bit for connection to establish await new Promise(resolve => setTimeout(resolve, 1000)) if (!relayHub.isConnected.value) { console.warn('Still not connected, skipping peer subscriptions') return } } // Subscribe to each peer for notifications for (const peer of peers) { try { await subscribeToPeerForNotifications(peer.pubkey) } catch (error) { console.error(`Failed to subscribe to peer ${peer.username} (${peer.pubkey}):`, error) } } } return { // State isConnected: readonly(isConnected), messages: readonly(messages), isLoggedIn: readonly(isAuthenticated), peers: readonly(peers), // Reactive computed properties totalUnreadCount: computed(() => getTotalUnreadCount()), // Methods connect, disconnect, subscribeToPeer, subscribeToPeerForNotifications, sendMessage, getMessages, clearMessages, onMessageAdded, // Notification methods markMessagesAsRead, getUnreadCount, getAllUnreadCounts, getTotalUnreadCount, clearAllUnreadCounts, clearProcessedMessageIds, debugUnreadData, getUnreadData, // Timestamp methods (for sorting) getLatestMessageTimestamp, getAllLatestMessageTimestamps, // Peer management methods loadPeers, subscribeToAllPeersForNotifications, currentUser, hasNostrKeys, getNostrKeyStatus } } // Export singleton instance for global state export const nostrChat = useNostrChat()