- Introduce a comprehensive roadmap for integrating nostr-market-app purchasing functionality into the web-app. - Outline key components of the shopping cart system, checkout process, and order management. - Detail phased implementation strategy, including enhanced user experience and advanced features. - Include security, performance, and testing considerations to ensure robust integration. feat: Enhance market store with new order and cart management features - Introduce new interfaces for Order, OrderItem, ContactInfo, and ShippingZone to support enhanced order management. - Update Stall and Product interfaces to include currency and shipping details. - Implement a comprehensive shopping cart system with stall-specific carts, including methods for adding, removing, and updating items. - Add payment-related interfaces and methods for managing payment requests and statuses. - Enhance filter options to include in-stock status and payment methods, improving product filtering capabilities. - Refactor computed properties and methods for better cart management and checkout processes. feat: Implement shopping cart functionality with new components and routing - Add ShoppingCart, CartItem, and CartSummary components to manage cart items and display summaries. - Introduce Cart.vue page to serve as the main shopping cart interface, integrating cart and summary components. - Update Navbar.vue to include a cart icon with item count, enhancing user navigation. - Implement cart management features in the market store, including item addition, quantity updates, and removal. - Establish routing for the cart page, ensuring seamless navigation for users. - Enhance ProductCard.vue to support adding items to the cart directly from the product listing. feat: Update cart and checkout functionality with improved navigation and button labels - Change "Proceed to Checkout" button text to dynamic "Place Order" based on context in CartSummary.vue. - Update "Continue Shopping" button to "Back to Cart" in CartSummary.vue for clearer navigation. - Modify routing for checkout to include stall ID in ShoppingCart.vue, enhancing checkout process. - Simplify Cart.vue by removing CartSummary component and focusing on ShoppingCart display. - Add new route for checkout with stall ID in router configuration for better handling of checkout flows. feat: Enhance cart and checkout components with improved shipping address handling - Update CartSummary.vue to use readonly types for cart items and shipping zones, ensuring immutability. - Modify Checkout.vue to conditionally display the shipping address field based on the selected shipping zone's requirements for physical shipping. - Add a digital delivery note for products that do not require a shipping address. - Introduce a computed property to determine if a shipping address is required, improving validation logic during checkout. - Update market store to include a new property for shipping zones indicating if physical shipping is required. feat: Implement order placement functionality in checkout process - Add a "Place Order" button in Checkout.vue that triggers the order placement process. - Introduce loading state during order placement to enhance user experience. - Implement createAndPlaceOrder method in market store to handle order creation and status updates. - Include error handling for order placement failures, providing user feedback on errors. - Update checkout logic to validate shipping zone and contact information before proceeding. feat: Add Order History page and update Navbar for order tracking - Introduce a new OrderHistory.vue page to display users' past orders with filtering and sorting options. - Update Navbar.vue to include an "Order History" option with a badge showing the count of orders. - Implement computed properties for order count and enhance user navigation experience. feat: Integrate Nostr functionality for order management and user notifications - Add NostrExtensionGuide component to inform users about the required Nostr extension for order transmission. - Implement useNostrOrders composable to manage Nostr connection, event creation, and order sending. - Update Checkout.vue to display Nostr connection status and provide feedback on order transmission. - Enhance OrderHistory.vue to show Nostr transmission status and details for each order. - Modify market store to handle Nostr event details and errors during order placement, ensuring local fallback. - Introduce types for Nostr events to improve type safety and integration with the existing order management system. refactor: Update Nostr relay configuration to use environment variable - Change DEFAULT_RELAYS to dynamically retrieve relay URLs from the VITE_MARKET_RELAYS environment variable. - Add error handling to ensure relays are configured before establishing a connection. - Modify createBlankEvent function to return a more precise type. - Update event signing process to ensure the event ID is generated correctly before signing. refactor: useAuth switch Enhance Nostr order management with authentication checks - Integrate user authentication checks to ensure Nostr features are only accessible to authenticated users. - Replace direct window.nostr calls with auth store methods for retrieving public and private keys. - Implement a helper function for signing events and mock encryption for order content. - Remove obsolete Nostr type definitions to streamline the codebase. feat: Enhance Checkout.vue with Nostr processing feedback and cleanup - Update the checkout button to disable based on order placement state. - Simplify order placement feedback by removing unnecessary Nostr processing checks. - Introduce a new visual indicator for Nostr order processing status. - Refactor computed properties for better clarity and efficiency in shipping zone handling. refactor: Streamline Nostr order handling and integrate buyer public key retrieval - Remove redundant Nostr relay tag from order event creation in useNostrOrders. - Update Checkout.vue to retrieve the buyer's public key from the auth store, enhancing order placement logic. - Modify createAndPlaceOrder method in market store to accept an optional Nostr orders instance for improved flexibility in order processing. refactor: Remove Nostr-related components and streamline order processing - Delete NostrExtensionGuide.vue and associated type definitions to simplify the codebase. - Remove unused useNostr.ts file and related logic from useNostrOrders.ts. - Update order handling in market store to directly integrate Nostr publishing without relying on external components. - Enhance Checkout.vue and Cart.vue to reflect changes in Nostr integration and provide clearer order status feedback. feat: Enhance Nostr chat functionality with malformed message handling - Introduce tracking for malformed message IDs to prevent repeated processing attempts. - Implement functions to mark messages as malformed, clean up old entries, and retrieve statistics on malformed messages. - Add periodic cleanup of malformed messages to manage memory usage. - Enhance message processing logic to skip previously identified malformed messages and provide detailed error handling for decryption failures. - Update the return object to include new functions for managing malformed messages. ZZ feat: Implement Lightning invoice management in market store - Add functionality to create and manage Lightning invoices for orders. - Introduce payment monitoring and status updates for invoices. - Implement payment confirmation messaging via Nostr upon successful payment. - Enhance order interface to include new fields for Lightning invoice details and payment status. ZZ feat: Enhance OrderHistory.vue with payment status indicators and invoice management - Add visual indicators for payment status, including 'Paid' and 'Payment Pending' badges. - Implement expandable payment display for orders with Lightning invoices. - Introduce functionality to toggle payment display and generate Lightning invoices. - Update order status messaging to reflect payment requirements and invoice generation status. ZZ feat: Enhance OrderHistory.vue with payment status indicators and invoice management - Add visual indicators for payment status, including 'Paid' and 'Payment Pending' badges. - Implement expandable payment display for orders with Lightning invoices. - Introduce functionality to toggle payment display and generate Lightning invoices. - Update order status messaging to reflect payment requirements and invoice generation status. feat: Implement order event handling in useOrderEvents composable - Introduce useOrderEvents composable to manage subscription and processing of order-related events. - Define order event types and interfaces for better type safety and clarity. - Implement methods to handle payment requests, order status updates, and invoice generation. - Enhance OrderHistory.vue to display order event subscription status and last update timestamp. - Update market store to include order update functionality for better integration with order events. FIX: Build errors refactor: Update component styles and improve UI consistency across market pages - Replace various color classes with updated design tokens for better consistency. - Change background colors of components to align with the new design system. - Update text colors to enhance readability and maintain a cohesive look. - Refactor class names in CartItem.vue, CartSummary.vue, DashboardOverview.vue, and other components to use the new color scheme. - Ensure all components reflect the updated design guidelines for a unified user experience. refactor: Remove Order History references from Navbar component - Eliminate order count computation and related UI elements from the Navbar. - Streamline the Navbar by removing the Order History button and badge. - Maintain existing functionality for other menu items, ensuring a cleaner user interface. feat: Implement QR code generation and download functionality in PaymentDisplay component - Add QR code generation for payment requests using the qrcode library. - Enhance UI to display loading states and error messages during QR code generation. - Introduce a download button for users to save the generated QR code. - Implement logic to regenerate QR code when the invoice changes. refactor: Replace useRelayHub with relayHubComposable across components - Update imports in multiple components and composables to use the new relayHubComposable for better consistency and maintainability. - Enhance OrderHistory.vue with debug information for development, displaying key states related to orders, authentication, and relay hub connectivity. - Remove unnecessary reconnect button from RelayHubStatus.vue to streamline user interactions. - Improve logging in useOrderEvents for better debugging and monitoring of order event subscriptions. refactor: Update OrderHistory.vue styles for improved UI consistency - Replace color classes with updated design tokens for better alignment with the new design system. - Enhance readability by adjusting text colors and background styles for payment status indicators. - Ensure a cohesive look across the component by standardizing class names and styles. refactor: Update component styles for improved UI consistency across checkout pages - Replace color classes with updated design tokens for better alignment with the new design system. - Enhance readability by adjusting text colors and background styles in CartSummary.vue, PaymentDisplay.vue, Checkout.vue, and OrderHistory.vue. - Standardize class names and styles to ensure a cohesive look across all components. feat: Implement invoice generation and Nostr integration in MerchantStore component - Add functionality to generate Lightning invoices for orders and send them to customers via Nostr. - Introduce a new sendInvoiceToCustomer method to update order details and publish invoice information. - Enhance order event handling in useOrderEvents to update existing orders with new invoice data. - Improve error handling and logging for invoice generation and sending processes. feat: Enhance MerchantStore and PaymentDisplay components for improved invoice handling - Add wallet indicator in MerchantStore to display the selected wallet name during pending orders. - Implement temporary fixes for missing buyer and seller public keys when generating invoices. - Update invoice generation logic to utilize the first available wallet and improve error handling. - Modify PaymentDisplay to use the new bolt11 field for payment requests and enhance date formatting. - Refactor order event handling to ensure accurate updates and invoice management across components. feat: Enhance order event processing in useOrderEvents composable - Refactor processOrderEvent to handle incoming Nostr market order events with improved validation and logging. - Implement logic to update existing orders or create new ones based on event data, ensuring accurate order management. - Add detailed console logging for better debugging and tracking of order events and their statuses. - Ensure compatibility with market order structure and invoice details for seamless integration with payment processing. feat: Enhance order management with localStorage persistence - Update createOrder method to optionally accept an order ID from events, improving order tracking. - Convert items from readonly to mutable for better manipulation. - Implement localStorage persistence for orders, ensuring data is saved and loaded across sessions. - Add methods to save and load orders from localStorage, enhancing user experience and data reliability. feat: Update invoice creation to support additional metadata and nostrmarket compatibility - Modify createInvoice method to accept an optional extra parameter for additional metadata. - Change invoice tag to 'nostrmarket' for improved compatibility with Nostr market. - Include merchant and buyer public keys in the invoice data for better integration. - Update invoice creation in market store to utilize new parameters for enhanced functionality. feat: Enhance order and invoice handling for Nostr market compatibility - Add originalOrderId to order events for tracking Nostr order IDs. - Update invoice creation to utilize original Nostr order ID when generating invoices. - Improve logging for invoice requests to LNBits, providing better visibility into the data being sent. - Ensure compatibility with nostrmarket by adjusting order ID handling in the market store. fix: Refine invoice creation logic for Nostr market compatibility - Adjust order ID handling in invoice creation to prioritize originalOrderId for better compatibility with nostrmarket. - Enhance logging to provide clearer insights into the order ID being used during invoice generation. feat: Integrate nostrmarket service for order publishing and merchant catalog management - Implement functionality to publish orders via the nostrmarket protocol, replacing the previous Nostr integration. - Add methods to publish merchant catalogs, including stalls and products, to nostrmarket with event ID tracking. - Enhance order interface to include nostrEventId for better integration with nostrmarket. - Improve error handling and logging for nostrmarket publishing processes. refactor: Simplify order creation logic in useOrderEvents and update contact structure in nostrmarketService - Streamline order creation by using event.id and defaulting to 'unknown' for stallId. - Update contact structure to include address and message, removing optional email and phone fields for clarity. - Ensure compatibility with new order data structure for improved integration with nostrmarket. feat: Add bech32 to hex conversion utility and integrate into nostrmarketService - Implement a new utility function to convert bech32 keys to hex format, enhancing key handling. - Update nostrmarketService to utilize the new conversion function for user public and private keys. - Modify contact structure to include additional fields for improved order information management. feat: Add nostrclient configuration to AppConfig for enhanced Nostr integration - Introduce a new nostrclient property in AppConfig to manage Nostr client settings. - Include url and enabled fields to configure the Nostr client connection dynamically. - Ensure compatibility with environment variables for flexible deployment configurations. feat: Introduce comprehensive order management and fulfillment documentation - Add ORDER_MANAGEMENT_FULFILLMENT.md to detail the complete order lifecycle, including order states, data models, and merchant/customer interfaces. - Implement test scripts for verifying order and payment request formats in test-nostrmarket-format.js. - Create PaymentRequestDialog.vue for handling payment requests with dynamic options and QR code generation. - Enhance useOrderEvents.ts to process nostrmarket protocol messages for order management. - Update nostrmarketService.ts to handle payment requests and order status updates, ensuring seamless integration with the marketplace. - Integrate payment request dialog in Market.vue and manage its state in the market store. refactor: Remove obsolete test script for nostrmarket order format - Delete test-nostrmarket-format.js as it is no longer needed for verifying order and payment request formats. - Update PaymentRequestDialog.vue to enhance UI components and integrate QR code generation for payment requests. - Refactor payment handling and notification logic to utilize toast notifications instead of Quasar's notify system. feat: Enhance OrderHistory component with payment request handling and QR code generation - Add UI elements to display payment request status and options in OrderHistory.vue. - Implement functions to copy payment requests, open Lightning wallets, and download QR codes. - Update nostrmarketService to generate QR codes for payment requests and manage order statuses effectively. - Remove obsolete PaymentRequestDialog integration from Market.vue for a cleaner UI. feat: Add debug information and toast notifications in OrderHistory component - Introduce debug info display for payment requests and hashes in OrderHistory.vue. - Implement toast notifications for actions like copying payment requests, opening wallets, and downloading QR codes. - Enhance error handling with user feedback for various order-related actions. - Remove obsolete payment request dialog methods from market store for cleaner code. feat: Revamp CartItem and ShoppingCart components for improved layout and functionality - Enhance CartItem.vue with responsive design for desktop and mobile views, including better organization of product details, price, quantity controls, and remove button. - Update ShoppingCart.vue to separate desktop and mobile layouts, improving the user experience with clearer action buttons and cart summary display. - Implement consistent styling and layout adjustments for better visual coherence across different screen sizes.
928 lines
No EOL
28 KiB
TypeScript
928 lines
No EOL
28 KiB
TypeScript
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 { relayHubComposable } 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<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() {
|
|
// Use the centralized relay hub
|
|
const relayHub = relayHubComposable
|
|
|
|
// Use the main authentication system
|
|
const auth = useAuth()
|
|
|
|
// State
|
|
const messages = ref<Map<string, ChatMessage[]>>(new Map())
|
|
const processedMessageIds = ref(new Set<string>())
|
|
const onMessageAdded = ref<((peerPubkey: string) => void) | 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())
|
|
|
|
// Track peers globally
|
|
const peers = ref<any[]>([])
|
|
|
|
// Track malformed message IDs to prevent repeated processing attempts
|
|
const malformedMessageIds = ref(new Set<string>())
|
|
|
|
// Mark a message as malformed to prevent future processing attempts
|
|
const markMessageAsMalformed = (eventId: string) => {
|
|
malformedMessageIds.value.add(eventId)
|
|
// Also mark as processed to prevent retries
|
|
processedMessageIds.value.add(eventId)
|
|
}
|
|
|
|
// Clean up old malformed messages (call this periodically)
|
|
const cleanupMalformedMessages = () => {
|
|
// const now = Math.floor(Date.now() / 1000)
|
|
// const maxAge = 24 * 60 * 60 // 24 hours
|
|
|
|
// Clear old malformed message IDs to free memory
|
|
// This is a simple cleanup - in production you might want more sophisticated tracking
|
|
if (malformedMessageIds.value.size > 1000) {
|
|
console.log('Cleaning up malformed message tracking (clearing all)')
|
|
malformedMessageIds.value.clear()
|
|
}
|
|
}
|
|
|
|
// Set up periodic cleanup
|
|
let cleanupInterval: NodeJS.Timeout | null = null
|
|
|
|
// Clean up resources
|
|
const cleanup = () => {
|
|
if (cleanupInterval) {
|
|
clearInterval(cleanupInterval)
|
|
cleanupInterval = null
|
|
console.log('Cleaned up malformed message tracking interval')
|
|
}
|
|
}
|
|
|
|
// Manually clear all malformed message tracking
|
|
const clearAllMalformedMessages = () => {
|
|
const count = malformedMessageIds.value.size
|
|
malformedMessageIds.value.clear()
|
|
console.log(`Cleared ${count} malformed message IDs from tracking`)
|
|
}
|
|
|
|
// Get statistics about malformed messages
|
|
const getMalformedMessageStats = () => {
|
|
return {
|
|
totalMalformed: malformedMessageIds.value.size,
|
|
totalProcessed: processedMessageIds.value.size,
|
|
malformedIds: Array.from(malformedMessageIds.value).slice(0, 10) // First 10 for debugging
|
|
}
|
|
}
|
|
|
|
// 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<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 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()
|
|
}
|
|
|
|
// Set up periodic cleanup of malformed messages
|
|
if (!cleanupInterval) {
|
|
cleanupInterval = setInterval(cleanupMalformedMessages, 5 * 60 * 1000) // Every 5 minutes
|
|
console.log('Set up periodic cleanup of malformed messages')
|
|
}
|
|
|
|
} 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
|
|
}
|
|
|
|
// Check if this message was previously identified as malformed
|
|
if (malformedMessageIds.value.has(event.id)) {
|
|
console.log('Skipping previously identified malformed message:', 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
|
|
|
|
// Check for malformed messages before attempting decryption
|
|
if (typeof event.content !== 'string' || event.content.length === 0) {
|
|
console.warn('Skipping message with invalid content format:', {
|
|
eventId: event.id,
|
|
contentType: typeof event.content,
|
|
contentLength: event.content?.length
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check for our old placeholder encryption format
|
|
if (event.content.includes('[ENCRYPTED]') && event.content.includes('[ENCRYPTED]')) {
|
|
console.warn('Skipping message with old placeholder encryption format:', {
|
|
eventId: event.id,
|
|
content: event.content.substring(0, 100) + '...'
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check for other common malformed patterns
|
|
if (event.content.startsWith('[') || event.content.includes('ENCRYPTED')) {
|
|
console.warn('Skipping message with suspicious encryption format:', {
|
|
eventId: event.id,
|
|
content: event.content.substring(0, 100) + '...'
|
|
})
|
|
return
|
|
}
|
|
|
|
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) {
|
|
// Provide more specific error handling for different types of failures
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
|
|
// Check for specific error patterns that indicate malformed messages
|
|
if (errorMessage.includes('join.decode') || errorMessage.includes('input should be string')) {
|
|
console.warn('Skipping malformed message (invalid NIP-04 format):', {
|
|
eventId: event.id,
|
|
pubkey: event.pubkey,
|
|
error: errorMessage,
|
|
contentPreview: typeof event.content === 'string' ? event.content.substring(0, 100) + '...' : 'Invalid content type'
|
|
})
|
|
markMessageAsMalformed(event.id)
|
|
return
|
|
}
|
|
|
|
if (errorMessage.includes('Invalid byte sequence') || errorMessage.includes('hex string')) {
|
|
console.warn('Skipping message with invalid hex encoding:', {
|
|
eventId: event.id,
|
|
pubkey: event.pubkey,
|
|
error: errorMessage
|
|
})
|
|
markMessageAsMalformed(event.id)
|
|
return
|
|
}
|
|
|
|
// For other decryption errors, log with more context
|
|
console.error('Failed to decrypt message:', {
|
|
eventId: event.id,
|
|
pubkey: event.pubkey,
|
|
error: errorMessage,
|
|
contentType: typeof event.content,
|
|
contentLength: event.content?.length,
|
|
contentPreview: typeof event.content === 'string' ? event.content.substring(0, 100) + '...' : 'Invalid content type'
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
markMessageAsMalformed,
|
|
cleanupMalformedMessages,
|
|
clearAllMalformedMessages, // Add the new function to the return object
|
|
cleanup, // Add the cleanup function to the return object
|
|
getMalformedMessageStats // Add the new function to the return object
|
|
}
|
|
}
|
|
|
|
// Export singleton instance for global state
|
|
export const nostrChat = useNostrChat()
|