refactor: Replace Nostr chat preloader with a singleton pattern for improved state management

- Remove the useNostrChatPreloader composable and integrate its functionality into the useNostrChat composable, streamlining chat data handling.
- Update App.vue and ChatComponent to utilize the new singleton instance for managing chat connections and peer subscriptions.
- Enhance Navbar and ChatComponent to reflect changes in unread message tracking and peer management, improving user experience.
- Ensure proper error handling and logging during chat connection and peer loading processes.
This commit is contained in:
padreug 2025-08-09 15:19:52 +02:00
parent 855a003962
commit 2dec184c42
5 changed files with 160 additions and 209 deletions

View file

@ -8,28 +8,39 @@ import { Toaster } from '@/components/ui/sonner'
import 'vue-sonner/style.css' import 'vue-sonner/style.css'
import { auth } from '@/composables/useAuth' import { auth } from '@/composables/useAuth'
import { useMarketPreloader } from '@/composables/useMarketPreloader' import { useMarketPreloader } from '@/composables/useMarketPreloader'
import { useNostrChatPreloader } from '@/composables/useNostrChatPreloader' import { nostrChat } from '@/composables/useNostrChat'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
const route = useRoute() const route = useRoute()
const showLoginDialog = ref(false) const showLoginDialog = ref(false)
// Initialize preloaders // Initialize preloader
const marketPreloader = useMarketPreloader() const marketPreloader = useMarketPreloader()
const chatPreloader = useNostrChatPreloader()
// Hide navbar on login page // Hide navbar on login page
const showNavbar = computed(() => { const showNavbar = computed(() => {
return route.path !== '/login' return route.path !== '/login'
}) })
function handleLoginSuccess() { async function handleLoginSuccess() {
showLoginDialog.value = false showLoginDialog.value = false
toast.success('Welcome back!') toast.success('Welcome back!')
// Trigger preloading after successful login // Trigger preloading after successful login
marketPreloader.preloadMarket() marketPreloader.preloadMarket()
chatPreloader.preloadChat()
// Connect to chat
if (!nostrChat.isConnected.value) {
try {
await nostrChat.connect()
// Load peers and subscribe to all for notifications
const peers = await nostrChat.loadPeers()
await nostrChat.subscribeToAllPeersForNotifications(peers)
} catch (error) {
console.error('Failed to initialize chat:', error)
}
}
} }
onMounted(async () => { onMounted(async () => {
@ -42,15 +53,23 @@ onMounted(async () => {
}) })
// Watch for authentication changes and trigger preloading // Watch for authentication changes and trigger preloading
watch(() => auth.isAuthenticated.value, (isAuthenticated) => { watch(() => auth.isAuthenticated.value, async (isAuthenticated) => {
if (isAuthenticated) { if (isAuthenticated) {
if (!marketPreloader.isPreloaded.value) { if (!marketPreloader.isPreloaded.value) {
console.log('User authenticated, triggering market preload...') console.log('User authenticated, triggering market preload...')
marketPreloader.preloadMarket() marketPreloader.preloadMarket()
} }
if (!chatPreloader.isPreloaded.value) { if (!nostrChat.isConnected.value) {
console.log('User authenticated, triggering chat preload...') console.log('User authenticated, connecting to chat...')
chatPreloader.preloadChat() try {
await nostrChat.connect()
// Load peers and subscribe to all for notifications
const peers = await nostrChat.loadPeers()
await nostrChat.subscribeToAllPeersForNotifications(peers)
} catch (error) {
console.error('Failed to initialize chat:', error)
}
} }
} }
}, { immediate: true }) }, { immediate: true })

View file

@ -13,7 +13,7 @@ import ProfileDialog from '@/components/auth/ProfileDialog.vue'
import CurrencyDisplay from '@/components/ui/CurrencyDisplay.vue' import CurrencyDisplay from '@/components/ui/CurrencyDisplay.vue'
import { auth } from '@/composables/useAuth' import { auth } from '@/composables/useAuth'
import { useMarketPreloader } from '@/composables/useMarketPreloader' import { useMarketPreloader } from '@/composables/useMarketPreloader'
import { useNostrChatPreloader } from '@/composables/useNostrChatPreloader' import { nostrChat } from '@/composables/useNostrChat'
interface NavigationItem { interface NavigationItem {
name: string name: string
@ -27,7 +27,6 @@ const isOpen = ref(false)
const showLoginDialog = ref(false) const showLoginDialog = ref(false)
const showProfileDialog = ref(false) const showProfileDialog = ref(false)
const marketPreloader = useMarketPreloader() const marketPreloader = useMarketPreloader()
const chatPreloader = useNostrChatPreloader()
const navigation = computed<NavigationItem[]>(() => [ const navigation = computed<NavigationItem[]>(() => [
{ name: t('nav.home'), href: '/' }, { name: t('nav.home'), href: '/' },
@ -45,9 +44,9 @@ const totalBalance = computed(() => {
}, 0) }, 0)
}) })
// Compute total unread messages // Compute total unread messages (reactive)
const totalUnreadMessages = computed(() => { const totalUnreadMessages = computed(() => {
return chatPreloader.getTotalUnreadCount() return nostrChat.totalUnreadCount.value
}) })
const toggleMenu = () => { const toggleMenu = () => {

View file

@ -13,8 +13,8 @@
Disconnected Disconnected
</Badge> </Badge>
<!-- Total unread count --> <!-- Total unread count -->
<Badge v-if="getTotalUnreadCount() > 0" variant="destructive" class="text-xs"> <Badge v-if="totalUnreadCount > 0" variant="destructive" class="text-xs">
{{ getTotalUnreadCount() }} unread {{ totalUnreadCount }} unread
</Badge> </Badge>
</div> </div>
<Button @click="refreshPeers" :disabled="isLoading" size="sm"> <Button @click="refreshPeers" :disabled="isLoading" size="sm">
@ -198,8 +198,8 @@
Disconnected Disconnected
</Badge> </Badge>
<!-- Total unread count --> <!-- Total unread count -->
<Badge v-if="getTotalUnreadCount() > 0" variant="destructive" class="text-xs"> <Badge v-if="totalUnreadCount > 0" variant="destructive" class="text-xs">
{{ getTotalUnreadCount() }} unread {{ totalUnreadCount }} unread
</Badge> </Badge>
</div> </div>
<Button @click="refreshPeers" :disabled="isLoading" size="sm"> <Button @click="refreshPeers" :disabled="isLoading" size="sm">
@ -375,8 +375,8 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useNostrChat } from '@/composables/useNostrChat' import { nostrChat } from '@/composables/useNostrChat'
import { useNostrChatPreloader } from '@/composables/useNostrChatPreloader'
import { useFuzzySearch } from '@/composables/useFuzzySearch' import { useFuzzySearch } from '@/composables/useFuzzySearch'
// Types // Types
@ -386,15 +386,12 @@ interface Peer {
pubkey: string pubkey: string
} }
// Initialize preloader and chat
const chatPreloader = useNostrChatPreloader()
// State // State
const peers = computed(() => chatPreloader.peers.value) const peers = computed(() => nostrChat.peers.value)
const selectedPeer = ref<Peer | null>(null) const selectedPeer = ref<Peer | null>(null)
const messageInput = ref('') const messageInput = ref('')
const isLoading = computed(() => chatPreloader.isPreloading.value) const isLoading = ref(false)
const showChat = ref(false) const showChat = ref(false)
const messagesScrollArea = ref<HTMLElement | null>(null) const messagesScrollArea = ref<HTMLElement | null>(null)
const messagesContainer = ref<HTMLElement | null>(null) const messagesContainer = ref<HTMLElement | null>(null)
@ -403,7 +400,7 @@ const scrollTarget = ref<HTMLElement | null>(null)
// Mobile detection // Mobile detection
const isMobile = ref(false) const isMobile = ref(false)
// Nostr chat composable // Nostr chat composable (singleton)
const { const {
isConnected, isConnected,
messages, messages,
@ -414,9 +411,9 @@ const {
onMessageAdded, onMessageAdded,
markMessagesAsRead, markMessagesAsRead,
getUnreadCount, getUnreadCount,
getTotalUnreadCount, totalUnreadCount,
getLatestMessageTimestamp getLatestMessageTimestamp
} = useNostrChat() } = nostrChat
// Computed // Computed
const currentMessages = computed(() => { const currentMessages = computed(() => {
@ -505,7 +502,14 @@ const goBackToPeers = () => {
const refreshPeers = async () => { const refreshPeers = async () => {
console.log('Refreshing peers and chat data...') console.log('Refreshing peers and chat data...')
await chatPreloader.preloadChat() isLoading.value = true
try {
await nostrChat.loadPeers()
} catch (error) {
console.error('Failed to refresh peers:', error)
} finally {
isLoading.value = false
}
} }
const selectPeer = async (peer: Peer) => { const selectPeer = async (peer: Peer) => {
@ -600,22 +604,22 @@ onMounted(async () => {
} }
} }
console.log('Chat component mounted - checking if preloader has data...') console.log('Chat component mounted - checking connection state...')
// If chat is already preloaded, we're good to go // If not connected, connect
if (chatPreloader.isPreloaded.value) { if (!isConnected.value) {
console.log('Chat data was preloaded, connecting to chat...') console.log('Not connected, connecting to chat...')
await connect()
console.log('Chat connected successfully')
} else if (!chatPreloader.isPreloading.value) {
// If not preloaded and not currently preloading, trigger preload
console.log('Chat data not preloaded, triggering preload...')
await chatPreloader.preloadChat()
await connect() await connect()
} else { } else {
// Currently preloading, just connect console.log('Already connected to chat')
console.log('Chat is currently preloading, just connecting...') }
await connect()
// If no peers loaded, load them
if (peers.value.length === 0) {
console.log('No peers loaded, loading peers...')
await nostrChat.loadPeers()
} else {
console.log('Peers already loaded:', peers.value.length)
} }
}) })

View file

@ -78,6 +78,9 @@ export function useNostrChat() {
// Track latest message timestamp for each peer (for sorting) // Track latest message timestamp for each peer (for sorting)
const latestMessageTimestamps = ref<Map<string, number>>(new Map()) const latestMessageTimestamps = ref<Map<string, number>>(new Map())
// Store peers globally
const peers = ref<any[]>([])
// Computed // Computed
const isLoggedIn = computed(() => !!currentUser.value) const isLoggedIn = computed(() => !!currentUser.value)
@ -100,6 +103,15 @@ export function useNostrChat() {
return total return total
} }
// Reactive computed total unread count
const totalUnreadCount = computed(() => {
let total = 0
for (const count of unreadCounts.value.values()) {
total += count
}
return total
})
// Get latest message timestamp for a peer // Get latest message timestamp for a peer
const getLatestMessageTimestamp = (peerPubkey: string): number => { const getLatestMessageTimestamp = (peerPubkey: string): number => {
return latestMessageTimestamps.value.get(peerPubkey) || 0 return latestMessageTimestamps.value.get(peerPubkey) || 0
@ -798,11 +810,87 @@ export function useNostrChat() {
messages.value.delete(peerPubkey) 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
console.log(`Loaded ${loadedPeers.length} peers`)
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) {
console.log('No peers to subscribe to')
return
}
// Wait for connection to be established
if (!isConnected.value) {
console.log('Waiting for connection to be established before subscribing to peers')
// Wait a bit for connection to establish
await new Promise(resolve => setTimeout(resolve, 1000))
if (!isConnected.value) {
console.warn('Still not connected, skipping peer subscriptions')
return
}
}
console.log(`Subscribing to ${peers.length} peers for notifications`)
// 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)
}
}
console.log(`Successfully subscribed to ${peers.length} peers for notifications`)
}
return { return {
// State // State
isConnected: readonly(isConnected), isConnected: readonly(isConnected),
messages: readonly(messages), messages: readonly(messages),
isLoggedIn: readonly(isLoggedIn), isLoggedIn: readonly(isLoggedIn),
peers: readonly(peers),
// Reactive computed properties
totalUnreadCount: readonly(totalUnreadCount),
// Methods // Methods
connect, connect,
@ -825,6 +913,13 @@ export function useNostrChat() {
// Timestamp methods (for sorting) // Timestamp methods (for sorting)
getLatestMessageTimestamp, getLatestMessageTimestamp,
getAllLatestMessageTimestamps getAllLatestMessageTimestamps,
// Peer management methods
loadPeers,
subscribeToAllPeersForNotifications
} }
} }
// Export singleton instance for global state
export const nostrChat = useNostrChat()

View file

@ -1,166 +0,0 @@
import { ref, readonly } from 'vue'
import { useNostrChat } from './useNostrChat'
import { getAuthToken } from '@/lib/config/lnbits'
import { config } from '@/lib/config'
export interface Peer {
user_id: string
username: string
pubkey: string
}
export function useNostrChatPreloader() {
const isPreloading = ref(false)
const isPreloaded = ref(false)
const preloadError = ref<string | null>(null)
const peers = ref<Peer[]>([])
const chat = useNostrChat()
const preloadChat = async () => {
// Don't preload if already done or currently preloading
if (isPreloaded.value || isPreloading.value) {
return
}
try {
isPreloading.value = true
preloadError.value = null
console.log('Preloading chat data...')
// Connect to chat
await chat.connect()
// Load peers
await loadPeers()
// Subscribe to all peers for notifications (without loading full history)
if (peers.value.length > 0) {
console.log(`Subscribing to ${peers.value.length} peers for notifications`)
await subscribeToAllPeersForNotifications()
}
isPreloaded.value = true
console.log('Chat data preloaded successfully')
} catch (error) {
console.error('Failed to preload chat:', error)
preloadError.value = error instanceof Error ? error.message : 'Failed to preload chat'
// Don't throw error, let the UI handle it gracefully
} finally {
isPreloading.value = false
}
}
const loadPeers = async () => {
try {
const authToken = getAuthToken()
if (!authToken) {
console.warn('No authentication token found - cannot load peers')
return
}
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) {
const errorText = await response.text()
console.error('Peers API Error:', response.status, errorText)
throw new Error(`Failed to load peers: ${response.status}`)
}
const responseText = await response.text()
try {
const data = JSON.parse(responseText)
peers.value = data.map((peer: any) => ({
user_id: peer.user_id,
username: peer.username,
pubkey: peer.pubkey
}))
console.log(`Loaded ${peers.value.length} peers for chat preloader`)
} catch (parseError) {
console.error('JSON Parse Error for peers:', parseError)
throw new Error('Invalid JSON response from peers API')
}
} catch (error) {
console.error('Failed to load peers in preloader:', error)
throw error
}
}
// Subscribe to all peers for notifications (without loading full message history)
const subscribeToAllPeersForNotifications = async () => {
if (!peers.value.length) {
console.log('No peers to subscribe to')
return
}
// Wait for connection to be established
if (!chat.isConnected.value) {
console.log('Waiting for connection to be established before subscribing to peers')
// Wait a bit for connection to establish
await new Promise(resolve => setTimeout(resolve, 1000))
if (!chat.isConnected.value) {
console.warn('Still not connected, skipping peer subscriptions')
return
}
}
console.log(`Subscribing to ${peers.value.length} peers for notifications`)
let successCount = 0
let errorCount = 0
for (const peer of peers.value) {
try {
console.log(`Attempting to subscribe to peer: ${peer.pubkey} (${peer.username})`)
// Subscribe to peer for notifications only (don't load full history)
const subscription = await chat.subscribeToPeerForNotifications(peer.pubkey)
if (subscription) {
console.log(`Successfully subscribed to notifications for peer: ${peer.pubkey}`)
successCount++
} else {
console.warn(`Failed to create subscription for peer: ${peer.pubkey}`)
errorCount++
}
} catch (error) {
console.warn(`Failed to subscribe to peer ${peer.pubkey}:`, error)
errorCount++
}
}
console.log(`Chat preloader subscription summary: ${successCount} successful, ${errorCount} failed`)
}
const resetPreload = () => {
isPreloaded.value = false
preloadError.value = null
peers.value = []
}
return {
isPreloading: readonly(isPreloading),
isPreloaded: readonly(isPreloaded),
preloadError: readonly(preloadError),
peers: readonly(peers),
preloadChat,
resetPreload,
// Expose chat composable methods for global access
getTotalUnreadCount: chat.getTotalUnreadCount,
getUnreadCount: chat.getUnreadCount,
getAllUnreadCounts: chat.getAllUnreadCounts,
markMessagesAsRead: chat.markMessagesAsRead,
clearAllUnreadCounts: chat.clearAllUnreadCounts
}
}