- Introduce a computed property to sort peers based on the latest message timestamp and unread message count, enhancing the user experience by prioritizing relevant conversations. - Add methods to track and retrieve the latest message timestamp for each peer, ensuring accurate sorting. - Update the ChatComponent to utilize the new sorting logic, improving the display of peers in the chat interface. refactor: Reorganize fuzzy search and mobile detection logic in ChatComponent - Move fuzzy search implementation and mobile detection methods to improve code clarity and maintainability. - Ensure consistent functionality for searching peers by username or pubkey with typo tolerance. - Maintain mobile navigation logic for better user experience on smaller devices.
830 lines
No EOL
26 KiB
TypeScript
830 lines
No EOL
26 KiB
TypeScript
import { ref, computed, readonly } from 'vue'
|
|
|
|
import { SimplePool, nip04, finalizeEvent, type EventTemplate } from 'nostr-tools'
|
|
import { hexToBytes } from '@/lib/utils/crypto'
|
|
import { getAuthToken } from '@/lib/config/lnbits'
|
|
import { config } from '@/lib/config'
|
|
|
|
// 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<string> // 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() {
|
|
|
|
// State
|
|
const isConnected = ref(false)
|
|
const messages = ref<Map<string, ChatMessage[]>>(new Map())
|
|
const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null)
|
|
const processedMessageIds = ref(new Set<string>())
|
|
const onMessageAdded = ref<((peerPubkey: string) => void) | null>(null)
|
|
const pool = ref<SimplePool | null>(null)
|
|
|
|
// Reactive unread counts
|
|
const unreadCounts = ref<Map<string, number>>(new Map())
|
|
|
|
// Track latest message timestamp for each peer (for sorting)
|
|
const latestMessageTimestamps = ref<Map<string, number>>(new Map())
|
|
|
|
// Computed
|
|
const isLoggedIn = computed(() => !!currentUser.value)
|
|
|
|
// Get unread count for a peer
|
|
const getUnreadCount = (peerPubkey: string): number => {
|
|
return unreadCounts.value.get(peerPubkey) || 0
|
|
}
|
|
|
|
// Get all unread counts
|
|
const getAllUnreadCounts = (): Map<string, number> => {
|
|
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<string, number> => {
|
|
return new Map(latestMessageTimestamps.value)
|
|
}
|
|
|
|
// Update latest message timestamp for a peer
|
|
const updateLatestMessageTimestamp = (peerPubkey: string, timestamp: number): void => {
|
|
const currentLatest = latestMessageTimestamps.value.get(peerPubkey) || 0
|
|
if (timestamp > currentLatest) {
|
|
latestMessageTimestamps.value.set(peerPubkey, timestamp)
|
|
// Force reactivity
|
|
latestMessageTimestamps.value = new Map(latestMessageTimestamps.value)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Load unread counts from localStorage
|
|
const loadUnreadCounts = (): void => {
|
|
try {
|
|
const keys = Object.keys(localStorage).filter(key =>
|
|
key.startsWith(`${UNREAD_MESSAGES_KEY}-`)
|
|
)
|
|
|
|
console.log('Loading unread counts from localStorage. Found keys:', keys)
|
|
|
|
for (const key of keys) {
|
|
const peerPubkey = key.replace(`${UNREAD_MESSAGES_KEY}-`, '')
|
|
const unreadData = getUnreadData(peerPubkey)
|
|
console.log(`Peer ${peerPubkey}:`, {
|
|
lastReadTimestamp: unreadData.lastReadTimestamp,
|
|
unreadCount: unreadData.unreadCount,
|
|
processedMessageIdsCount: unreadData.processedMessageIds.size
|
|
})
|
|
|
|
if (unreadData.unreadCount > 0) {
|
|
unreadCounts.value.set(peerPubkey, unreadData.unreadCount)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to load unread counts from localStorage:', error)
|
|
}
|
|
}
|
|
|
|
// Initialize unread counts on startup
|
|
loadUnreadCounts()
|
|
|
|
// Clear all unread counts (for testing)
|
|
const clearAllUnreadCounts = (): void => {
|
|
unreadCounts.value.clear()
|
|
unreadCounts.value = new Map(unreadCounts.value)
|
|
|
|
// Also clear from localStorage
|
|
try {
|
|
const keys = Object.keys(localStorage).filter(key =>
|
|
key.startsWith(`${UNREAD_MESSAGES_KEY}-`)
|
|
)
|
|
|
|
for (const key of keys) {
|
|
localStorage.removeItem(key)
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to clear unread counts from localStorage:', error)
|
|
}
|
|
}
|
|
|
|
// Clear processed message IDs for a specific peer (useful for debugging)
|
|
const clearProcessedMessageIds = (peerPubkey: string): void => {
|
|
try {
|
|
const unreadData = getUnreadData(peerPubkey)
|
|
const updatedData: UnreadMessageData = {
|
|
...unreadData,
|
|
processedMessageIds: new Set()
|
|
}
|
|
saveUnreadData(peerPubkey, updatedData)
|
|
console.log(`Cleared processed message IDs for peer: ${peerPubkey}`)
|
|
} catch (error) {
|
|
console.warn('Failed to clear processed message IDs for peer:', peerPubkey, error)
|
|
}
|
|
}
|
|
|
|
// Debug function to show current state of unread data for a peer
|
|
const debugUnreadData = (peerPubkey: string): void => {
|
|
try {
|
|
const unreadData = getUnreadData(peerPubkey)
|
|
console.log(`Debug unread data for ${peerPubkey}:`, {
|
|
lastReadTimestamp: unreadData.lastReadTimestamp,
|
|
unreadCount: unreadData.unreadCount,
|
|
processedMessageIds: Array.from(unreadData.processedMessageIds),
|
|
processedMessageIdsCount: unreadData.processedMessageIds.size
|
|
})
|
|
} catch (error) {
|
|
console.warn('Failed to debug unread data for peer:', peerPubkey, error)
|
|
}
|
|
}
|
|
|
|
// Get relays from config - requires VITE_NOSTR_RELAYS to be set
|
|
const getRelays = (): NostrRelayConfig[] => {
|
|
const configuredRelays = config.nostr.relays
|
|
if (!configuredRelays || configuredRelays.length === 0) {
|
|
throw new Error('VITE_NOSTR_RELAYS environment variable must be configured for chat functionality')
|
|
}
|
|
|
|
return configuredRelays.map((url: string) => ({ url, read: true, write: true }))
|
|
}
|
|
|
|
// Initialize Nostr pool
|
|
const initializePool = () => {
|
|
if (!pool.value) {
|
|
pool.value = new SimplePool()
|
|
}
|
|
}
|
|
|
|
// Connect to relays
|
|
const connectToRelay = async (url: string): Promise<any> => {
|
|
try {
|
|
initializePool()
|
|
const relay = pool.value!.ensureRelay(url)
|
|
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 {
|
|
// Get current user from LNBits
|
|
await loadCurrentUser()
|
|
|
|
if (!currentUser.value) {
|
|
console.warn('No user logged in - chat functionality will be limited')
|
|
// Don't throw error, just continue without user data
|
|
// The chat will still work for viewing messages, but sending will fail
|
|
}
|
|
|
|
// Initialize pool
|
|
initializePool()
|
|
|
|
// Connect to relays
|
|
const relayConfigs = getRelays()
|
|
const relays = await Promise.all(
|
|
relayConfigs.map(relay => connectToRelay(relay.url))
|
|
)
|
|
|
|
const connectedRelays = relays.filter(relay => relay !== null)
|
|
isConnected.value = connectedRelays.length > 0
|
|
|
|
console.log(`Connected to ${connectedRelays.length} relays`)
|
|
} catch (error) {
|
|
console.error('Failed to connect:', error)
|
|
// Don't throw error, just log it and continue
|
|
// This allows the chat to still work for viewing messages
|
|
}
|
|
}
|
|
|
|
// Disconnect from relays
|
|
const disconnect = () => {
|
|
if (pool.value) {
|
|
const relayConfigs = getRelays()
|
|
pool.value.close(relayConfigs.map(r => r.url))
|
|
pool.value = null
|
|
}
|
|
isConnected.value = false
|
|
messages.value.clear()
|
|
processedMessageIds.value.clear()
|
|
}
|
|
|
|
// 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 messages from a specific peer
|
|
const subscribeToPeer = async (peerPubkey: string) => {
|
|
if (!currentUser.value) {
|
|
console.warn('No user logged in - cannot subscribe to peer messages')
|
|
return null
|
|
}
|
|
|
|
// Check if we have a pool and are connected
|
|
if (!pool.value) {
|
|
console.warn('No pool available - initializing...')
|
|
initializePool()
|
|
}
|
|
|
|
if (!isConnected.value) {
|
|
console.warn('Not connected to relays - attempting to connect...')
|
|
await connect()
|
|
}
|
|
|
|
if (!pool.value) {
|
|
throw new Error('Failed to initialize Nostr pool')
|
|
}
|
|
|
|
const myPubkey = currentUser.value.pubkey
|
|
|
|
// First, load historical messages
|
|
await loadHistoricalMessages(peerPubkey, myPubkey)
|
|
|
|
// Then subscribe to new messages
|
|
const relayConfigs = getRelays()
|
|
console.log('Subscribing to new messages for peer:', peerPubkey, 'with filters:', [
|
|
{
|
|
kinds: [4],
|
|
authors: [peerPubkey],
|
|
'#p': [myPubkey]
|
|
},
|
|
{
|
|
kinds: [4],
|
|
authors: [myPubkey],
|
|
'#p': [peerPubkey]
|
|
}
|
|
])
|
|
|
|
const sub = pool.value.subscribeMany(
|
|
relayConfigs.map(r => r.url),
|
|
[
|
|
{
|
|
kinds: [4],
|
|
authors: [peerPubkey],
|
|
'#p': [myPubkey]
|
|
},
|
|
{
|
|
kinds: [4],
|
|
authors: [myPubkey],
|
|
'#p': [peerPubkey]
|
|
}
|
|
],
|
|
{
|
|
onevent(event) {
|
|
console.log('Received live event:', event.id, 'author:', event.pubkey)
|
|
handleIncomingMessage(event, peerPubkey)
|
|
},
|
|
oneose() {
|
|
console.log('Subscription closed for peer:', peerPubkey)
|
|
}
|
|
}
|
|
)
|
|
|
|
return sub
|
|
}
|
|
|
|
// Subscribe to a peer for notifications only (without loading full message history)
|
|
const subscribeToPeerForNotifications = async (peerPubkey: string) => {
|
|
console.log('=== SUBSCRIBE TO PEER FOR NOTIFICATIONS START ===')
|
|
console.log('Peer pubkey:', peerPubkey)
|
|
|
|
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 (!pool.value) {
|
|
console.warn('No pool available - initializing...')
|
|
initializePool()
|
|
}
|
|
|
|
if (!isConnected.value) {
|
|
console.warn('Not connected to relays - attempting to connect...')
|
|
await connect()
|
|
}
|
|
|
|
if (!pool.value) {
|
|
throw new Error('Failed to initialize Nostr pool')
|
|
}
|
|
|
|
const myPubkey = currentUser.value.pubkey
|
|
console.log('My pubkey:', myPubkey)
|
|
|
|
// Subscribe to new messages only (no historical messages)
|
|
const relayConfigs = getRelays()
|
|
console.log('Subscribing to notifications for peer:', peerPubkey, 'with my pubkey:', myPubkey)
|
|
console.log('Using relays:', relayConfigs.map(r => r.url))
|
|
|
|
const filters = [
|
|
{
|
|
kinds: [4],
|
|
authors: [peerPubkey],
|
|
'#p': [myPubkey]
|
|
},
|
|
{
|
|
kinds: [4],
|
|
authors: [myPubkey],
|
|
'#p': [peerPubkey]
|
|
}
|
|
]
|
|
|
|
console.log('Notification subscription filters:', JSON.stringify(filters, null, 2))
|
|
|
|
const sub = pool.value.subscribeMany(
|
|
relayConfigs.map(r => r.url),
|
|
filters,
|
|
{
|
|
onevent(event) {
|
|
console.log('Received notification event:', {
|
|
id: event.id,
|
|
author: event.pubkey,
|
|
forPeer: peerPubkey,
|
|
tags: event.tags,
|
|
contentLength: event.content?.length || 0
|
|
})
|
|
handleIncomingMessage(event, peerPubkey)
|
|
},
|
|
oneose() {
|
|
console.log('Notification subscription closed for peer:', peerPubkey)
|
|
}
|
|
}
|
|
)
|
|
|
|
console.log('Successfully created notification subscription for peer:', peerPubkey)
|
|
console.log('=== SUBSCRIBE TO PEER FOR NOTIFICATIONS END ===')
|
|
return sub
|
|
}
|
|
|
|
// Load historical messages for a peer
|
|
const loadHistoricalMessages = async (peerPubkey: string, myPubkey: string) => {
|
|
console.log('Loading historical messages for peer:', peerPubkey)
|
|
console.log('My pubkey:', myPubkey)
|
|
|
|
const relayConfigs = getRelays()
|
|
console.log('Using relays:', relayConfigs.map(r => r.url))
|
|
|
|
const filters = [
|
|
{
|
|
kinds: [4],
|
|
authors: [peerPubkey],
|
|
'#p': [myPubkey]
|
|
},
|
|
{
|
|
kinds: [4],
|
|
authors: [myPubkey],
|
|
'#p': [peerPubkey]
|
|
}
|
|
]
|
|
|
|
console.log('Historical query filters:', filters)
|
|
|
|
const historicalSub = pool.value!.subscribeMany(
|
|
relayConfigs.map(r => r.url),
|
|
filters,
|
|
{
|
|
onevent(event) {
|
|
console.log('Received historical event:', {
|
|
id: event.id,
|
|
author: event.pubkey,
|
|
isSentByMe: event.pubkey === myPubkey,
|
|
contentLength: event.content.length
|
|
})
|
|
handleIncomingMessage(event, peerPubkey)
|
|
},
|
|
oneose() {
|
|
console.log('Historical query completed for peer:', peerPubkey)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Wait a bit for historical messages to load
|
|
await new Promise(resolve => setTimeout(resolve, 3000))
|
|
historicalSub.close()
|
|
console.log('Historical query closed for peer:', peerPubkey)
|
|
}
|
|
|
|
// Handle incoming message
|
|
const handleIncomingMessage = async (event: any, peerPubkey: string) => {
|
|
console.log('=== HANDLE INCOMING MESSAGE START ===')
|
|
console.log('Event ID:', event.id, 'Peer:', peerPubkey)
|
|
|
|
if (processedMessageIds.value.has(event.id)) {
|
|
console.log('Message already processed, skipping:', event.id)
|
|
return
|
|
}
|
|
|
|
processedMessageIds.value.add(event.id)
|
|
console.log('Added to processed messages:', event.id)
|
|
|
|
console.log('Handling incoming message:', {
|
|
eventId: event.id,
|
|
eventPubkey: event.pubkey,
|
|
myPubkey: currentUser.value!.pubkey,
|
|
peerPubkey,
|
|
isSentByMe: event.pubkey === currentUser.value!.pubkey
|
|
})
|
|
|
|
try {
|
|
// Decrypt the message
|
|
// 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
|
|
|
|
console.log('Decrypting message:', {
|
|
eventId: event.id,
|
|
isSentByMe,
|
|
eventPubkey: event.pubkey,
|
|
myPubkey: currentUser.value!.pubkey,
|
|
peerPubkey,
|
|
contentLength: event.content.length
|
|
})
|
|
|
|
const decryptedContent = await nip04.decrypt(
|
|
currentUser.value!.prvkey,
|
|
peerPubkey, // Always use peerPubkey for shared secret derivation
|
|
event.content
|
|
)
|
|
|
|
console.log('Successfully decrypted message:', {
|
|
eventId: event.id,
|
|
contentLength: decryptedContent.length,
|
|
contentPreview: decryptedContent.substring(0, 50) + '...'
|
|
})
|
|
|
|
const message: ChatMessage = {
|
|
id: event.id,
|
|
content: decryptedContent,
|
|
created_at: event.created_at,
|
|
sent: isSentByMe,
|
|
pubkey: event.pubkey
|
|
}
|
|
|
|
// Add message to the appropriate conversation
|
|
// Always use peerPubkey as the conversation key for both sent and received messages
|
|
const conversationKey = peerPubkey
|
|
|
|
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)
|
|
|
|
// 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)
|
|
|
|
// Track unread messages (only for received messages, not sent ones)
|
|
if (!isSentByMe) {
|
|
const unreadData = getUnreadData(peerPubkey)
|
|
|
|
console.log(`Processing unread message logic for ${peerPubkey}:`, {
|
|
messageId: event.id,
|
|
messageTimestamp: message.created_at,
|
|
lastReadTimestamp: unreadData.lastReadTimestamp,
|
|
currentUnreadCount: unreadData.unreadCount,
|
|
alreadyProcessed: unreadData.processedMessageIds.has(event.id),
|
|
processedMessageIdsCount: unreadData.processedMessageIds.size
|
|
})
|
|
|
|
// Check if this message is newer than the last read timestamp AND we haven't already counted it
|
|
if (message.created_at > unreadData.lastReadTimestamp && !unreadData.processedMessageIds.has(event.id)) {
|
|
// Add this message ID to the processed set
|
|
unreadData.processedMessageIds.add(event.id)
|
|
|
|
const updatedUnreadData: UnreadMessageData = {
|
|
lastReadTimestamp: unreadData.lastReadTimestamp,
|
|
unreadCount: unreadData.unreadCount + 1,
|
|
processedMessageIds: unreadData.processedMessageIds
|
|
}
|
|
|
|
saveUnreadData(peerPubkey, updatedUnreadData)
|
|
updateUnreadCount(peerPubkey, updatedUnreadData.unreadCount)
|
|
|
|
console.log(`✅ New unread message from ${peerPubkey}. Total unread: ${updatedUnreadData.unreadCount}`)
|
|
} else if (unreadData.processedMessageIds.has(event.id)) {
|
|
console.log(`⏭️ Message ${event.id} from ${peerPubkey} already counted as unread. Skipping.`)
|
|
} else {
|
|
console.log(`⏰ Message from ${peerPubkey} is older than last read timestamp. Skipping unread count.`)
|
|
}
|
|
} else {
|
|
console.log(`📤 Message from ${peerPubkey} was sent by current user. Skipping unread count.`)
|
|
}
|
|
|
|
// Trigger callback if set
|
|
if (onMessageAdded.value) {
|
|
onMessageAdded.value(conversationKey)
|
|
}
|
|
|
|
console.log('Messages for conversation:', messages.value.get(conversationKey)?.map(m => ({
|
|
id: m.id,
|
|
sent: m.sent,
|
|
content: m.content.substring(0, 30) + '...',
|
|
timestamp: m.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) {
|
|
throw new Error('No user logged in - please authenticate first')
|
|
}
|
|
|
|
// Check if we have a pool and are connected
|
|
if (!pool.value) {
|
|
console.warn('No pool available - initializing...')
|
|
initializePool()
|
|
}
|
|
|
|
if (!isConnected.value) {
|
|
console.warn('Not connected to relays - attempting to connect...')
|
|
await connect()
|
|
}
|
|
|
|
if (!pool.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
|
|
const relayConfigs = getRelays()
|
|
const publishPromises = relayConfigs.map(relay => {
|
|
return pool.value!.publish([relay.url], event)
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
return {
|
|
// State
|
|
isConnected: readonly(isConnected),
|
|
messages: readonly(messages),
|
|
isLoggedIn: readonly(isLoggedIn),
|
|
|
|
// Methods
|
|
connect,
|
|
disconnect,
|
|
subscribeToPeer,
|
|
subscribeToPeerForNotifications,
|
|
sendMessage,
|
|
getMessages,
|
|
clearMessages,
|
|
onMessageAdded,
|
|
|
|
// Notification methods
|
|
markMessagesAsRead,
|
|
getUnreadCount,
|
|
getAllUnreadCounts,
|
|
getTotalUnreadCount,
|
|
clearAllUnreadCounts,
|
|
clearProcessedMessageIds,
|
|
debugUnreadData,
|
|
|
|
// Timestamp methods (for sorting)
|
|
getLatestMessageTimestamp,
|
|
getAllLatestMessageTimestamps
|
|
}
|
|
}
|