Replace complex UnreadMessageData system with elegant path-based wildcard notification tracking inspired by Coracle's pattern. This simplifies the codebase while adding powerful batch "mark as read" capabilities.

Key changes:
  - Add notification store with path-based wildcard support (chat/*, chat/{pubkey}, *)
  - Remove UnreadMessageData interface and processedMessageIds Set tracking
  - Implement timestamp-based "seen at" logic with wildcard matching
  - Add markAllChatsAsRead() for batch operations
  - Integrate ChatNotificationConfig for module configuration
  - Create useNotifications composable for easy notification access

  Benefits:
  - Simpler architecture (removed processedMessageIds complexity)
  - Flexible wildcard-based "mark as read" operations
  - Future-proof for Primal-style backend sync
  - User-scoped storage via StorageService
  - Clean separation of concerns

refactor: enhance chat service with activity tracking and sorting

- Updated the ChatService to track lastSent, lastReceived, and lastChecked timestamps for peers, improving message handling and user experience.
- Implemented sorting of peers by last activity to prioritize recent conversations.
- Adjusted message handling to update peer activity based on message direction.
- Ensured updated peer data is saved to storage after modifications.

These changes streamline chat interactions and enhance the overall functionality of the chat service.

refactor: improve ChatService and notification store initialization

- Updated ChatService to ensure the notification store is initialized only after user authentication, preventing potential errors.
- Introduced a new method to safely access the notification store, enhancing error handling.
- Enhanced peer activity tracking by calculating last activity based on actual message timestamps, improving sorting and user experience.
- Added debounced saving of notification state to storage, optimizing performance and reducing unnecessary writes.
- Improved logging for better debugging and visibility into notification handling processes.

These changes enhance the reliability and efficiency of the chat service and notification management.

refactor: clean up logging in ChatService and notification store

- Removed unnecessary console logs from ChatService to streamline the code and improve performance.
- Simplified the initialization process of the notification store by eliminating redundant logging statements.
- Enhanced readability and maintainability of the code by focusing on essential operations without excessive debug output.

These changes contribute to a cleaner codebase and improved performance in chat service operations.

FIX BUILD ERRORS

refactor: update chat module notification configuration

- Refactored the notification settings in the chat module to use a nested structure, enhancing clarity and organization.
- Introduced `wildcardSupport` to the notification configuration, allowing for more flexible notification handling.
- Maintained existing functionality while improving the overall configuration structure.

These changes contribute to a more maintainable and extensible chat module configuration.

refactor: optimize ChatComponent and ChatService for improved performance

- Removed unnecessary sorting of peers in ChatComponent, leveraging the existing order provided by the chat service.
- Updated the useFuzzySearch composable to directly utilize the sorted peers, enhancing search efficiency.
- Cleaned up logging in ChatService by removing redundant console statements, streamlining the codebase.
- Added critical checks to prevent the current user from being included in peer interactions, improving user experience and functionality.

These changes contribute to a more efficient and maintainable chat module.

refactor: simplify message publishing in ChatService

- Removed unnecessary variable assignment in the message publishing process, directly awaiting the relayHub.publishEvent call.
- This change streamlines the code and enhances readability without altering functionality.

These modifications contribute to a cleaner and more efficient chat service implementation.
This commit is contained in:
padreug 2025-10-02 10:01:26 +02:00
parent 0447549fa5
commit 0da23e9332
8 changed files with 535 additions and 119 deletions

View file

@ -54,7 +54,12 @@ export const appConfig: AppConfig = {
config: { config: {
maxMessages: 500, maxMessages: 500,
autoScroll: true, autoScroll: true,
showTimestamps: true showTimestamps: true,
notifications: {
enabled: true,
soundEnabled: false,
wildcardSupport: true
}
} }
}, },
events: { events: {

View file

@ -415,22 +415,8 @@ const currentMessages = computed(() => {
return chat.currentMessages.value return chat.currentMessages.value
}) })
// Sort peers by unread count and name // NOTE: peers is already sorted correctly by the chat service (by activity: lastSent/lastReceived)
const sortedPeers = computed(() => { // We use it directly without re-sorting here
const sorted = [...peers.value].sort((a, b) => {
const aUnreadCount = getUnreadCount(a.pubkey)
const bUnreadCount = getUnreadCount(b.pubkey)
// First, sort by unread count (peers with unread messages appear first)
if (aUnreadCount > 0 && bUnreadCount === 0) return -1
if (aUnreadCount === 0 && bUnreadCount > 0) return 1
// Finally, sort alphabetically by name for peers with same unread status
return (a.name || '').localeCompare(b.name || '')
})
return sorted
})
// Fuzzy search for peers // Fuzzy search for peers
// This integrates the useFuzzySearch composable to provide intelligent search functionality // This integrates the useFuzzySearch composable to provide intelligent search functionality
@ -441,7 +427,7 @@ const {
isSearching, isSearching,
resultCount, resultCount,
clearSearch clearSearch
} = useFuzzySearch(sortedPeers, { } = useFuzzySearch(peers, {
fuseOptions: { fuseOptions: {
keys: [ keys: [
{ name: 'name', weight: 0.7 }, // Name has higher weight for better UX { name: 'name', weight: 0.7 }, // Name has higher weight for better UX

View file

@ -60,8 +60,12 @@ export function useChat() {
return chatService.addPeer(pubkey, name) return chatService.addPeer(pubkey, name)
} }
const markAsRead = (peerPubkey: string) => { const markAsRead = (peerPubkey: string, timestamp?: number) => {
chatService.markAsRead(peerPubkey) chatService.markAsRead(peerPubkey, timestamp)
}
const markAllChatsAsRead = () => {
chatService.markAllChatsAsRead()
} }
const refreshPeers = async () => { const refreshPeers = async () => {
@ -94,6 +98,7 @@ export function useChat() {
sendMessage, sendMessage,
addPeer, addPeer,
markAsRead, markAsRead,
markAllChatsAsRead,
refreshPeers refreshPeers
} }
} }

View file

@ -0,0 +1,77 @@
import { computed } from 'vue'
import { useChatNotificationStore } from '../stores/notification'
import type { ChatMessage } from '../types'
/**
* Composable for chat notification management
*
* Provides easy access to notification store functionality
* with computed properties and convenience methods.
*/
export function useChatNotifications() {
const notificationStore = useChatNotificationStore()
/**
* Get unread count for a specific chat
*/
const getUnreadCount = (peerPubkey: string, messages: ChatMessage[]): number => {
return notificationStore.getUnreadCount(peerPubkey, messages)
}
/**
* Check if a message has been seen
*/
const isMessageSeen = (peerPubkey: string, messageTimestamp: number): boolean => {
const path = `chat/${peerPubkey}`
return notificationStore.isSeen(path, messageTimestamp)
}
/**
* Mark a specific chat as read
*/
const markChatAsRead = (peerPubkey: string, timestamp?: number): void => {
notificationStore.markChatAsRead(peerPubkey, timestamp)
}
/**
* Mark all chats as read
*/
const markAllChatsAsRead = (): void => {
notificationStore.markAllChatsAsRead()
}
/**
* Mark everything (all notifications) as read
*/
const markAllAsRead = (): void => {
notificationStore.markAllAsRead()
}
/**
* Get the timestamp when a path was last marked as read
*/
const getSeenAt = (path: string, eventTimestamp: number): number => {
return notificationStore.getSeenAt(path, eventTimestamp)
}
/**
* Clear all notification state
*/
const clearAllNotifications = (): void => {
notificationStore.clearAll()
}
return {
// State
checked: computed(() => notificationStore.checked),
// Methods
getUnreadCount,
isMessageSeen,
markChatAsRead,
markAllChatsAsRead,
markAllAsRead,
getSeenAt,
clearAllNotifications,
}
}

View file

@ -26,8 +26,11 @@ export const chatModule: ModulePlugin = {
maxMessages: 500, maxMessages: 500,
autoScroll: true, autoScroll: true,
showTimestamps: true, showTimestamps: true,
notificationsEnabled: true, notifications: {
soundEnabled: false, enabled: true,
soundEnabled: false,
wildcardSupport: true
},
...options?.config ...options?.config
} }

View file

@ -2,9 +2,10 @@ import { ref, computed } from 'vue'
import { eventBus } from '@/core/event-bus' import { eventBus } from '@/core/event-bus'
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { nip04, finalizeEvent, type Event, type EventTemplate } from 'nostr-tools' import { nip04, finalizeEvent, type Event, type EventTemplate } from 'nostr-tools'
import type { ChatMessage, ChatPeer, UnreadMessageData, ChatConfig } from '../types' import type { ChatMessage, ChatPeer, ChatConfig } from '../types'
import { getAuthToken } from '@/lib/config/lnbits' import { getAuthToken } from '@/lib/config/lnbits'
import { config } from '@/lib/config' import { config } from '@/lib/config'
import { useChatNotificationStore } from '../stores/notification'
export class ChatService extends BaseService { export class ChatService extends BaseService {
// Service metadata // Service metadata
protected readonly metadata = { protected readonly metadata = {
@ -21,20 +22,35 @@ export class ChatService extends BaseService {
private visibilityUnsubscribe?: () => void private visibilityUnsubscribe?: () => void
private isFullyInitialized = false private isFullyInitialized = false
private authCheckInterval?: ReturnType<typeof setInterval> private authCheckInterval?: ReturnType<typeof setInterval>
private notificationStore?: ReturnType<typeof useChatNotificationStore>
constructor(config: ChatConfig) { constructor(config: ChatConfig) {
super() super()
this.config = config this.config = config
this.loadPeersFromStorage() // NOTE: DO NOT call loadPeersFromStorage() here - it depends on StorageService
// which may not be available yet. Moved to onInitialize().
} }
// Register market message handler for forwarding market-related DMs // Register market message handler for forwarding market-related DMs
setMarketMessageHandler(handler: (event: any) => Promise<void>) { setMarketMessageHandler(handler: (event: any) => Promise<void>) {
this.marketMessageHandler = handler this.marketMessageHandler = handler
} }
/**
* Get the notification store, ensuring it's initialized
* CRITICAL: This must only be called after onInitialize() has run
*/
private getNotificationStore(): ReturnType<typeof useChatNotificationStore> {
if (!this.notificationStore) {
throw new Error('ChatService: Notification store not initialized yet. This should not happen after onInitialize().')
}
return this.notificationStore
}
/** /**
* Service-specific initialization (called by BaseService) * Service-specific initialization (called by BaseService)
*/ */
protected async onInitialize(): Promise<void> { protected async onInitialize(): Promise<void> {
this.debug('Chat service onInitialize called') this.debug('Chat service onInitialize called')
// Check both injected auth service AND global auth composable // Check both injected auth service AND global auth composable
// Removed dual auth import // Removed dual auth import
const hasAuthService = this.authService?.user?.value?.pubkey const hasAuthService = this.authService?.user?.value?.pubkey
@ -83,6 +99,13 @@ export class ChatService extends BaseService {
return return
} }
this.debug('Completing chat service initialization...') this.debug('Completing chat service initialization...')
// CRITICAL: Initialize notification store AFTER user is authenticated
// StorageService needs user pubkey to scope the storage keys correctly
if (!this.notificationStore) {
this.notificationStore = useChatNotificationStore()
}
// Load peers from storage first // Load peers from storage first
this.loadPeersFromStorage() this.loadPeersFromStorage()
// Load peers from API // Load peers from API
@ -106,12 +129,59 @@ export class ChatService extends BaseService {
} }
// Computed properties // Computed properties
get allPeers() { get allPeers() {
return computed(() => Array.from(this.peers.value.values())) return computed(() => {
const peers = Array.from(this.peers.value.values())
// Sort by last activity (Coracle pattern)
// Most recent conversation first
return peers.sort((a, b) => {
// Calculate activity from actual messages (source of truth)
const aMessages = this.getMessages(a.pubkey)
const bMessages = this.getMessages(b.pubkey)
let aActivity = 0
let bActivity = 0
// Get last message timestamp from actual messages
if (aMessages.length > 0) {
const lastMsg = aMessages[aMessages.length - 1]
aActivity = lastMsg.created_at
} else {
// Fallback to stored timestamps only if no messages
aActivity = Math.max(a.lastSent || 0, a.lastReceived || 0)
}
if (bMessages.length > 0) {
const lastMsg = bMessages[bMessages.length - 1]
bActivity = lastMsg.created_at
} else {
// Fallback to stored timestamps only if no messages
bActivity = Math.max(b.lastSent || 0, b.lastReceived || 0)
}
// Peers with activity always come before peers without activity
if (aActivity > 0 && bActivity === 0) return -1
if (aActivity === 0 && bActivity > 0) return 1
// Primary sort: by activity timestamp (descending - most recent first)
if (bActivity !== aActivity) {
return bActivity - aActivity
}
// Stable tiebreaker: sort by pubkey (prevents random reordering)
return a.pubkey.localeCompare(b.pubkey)
})
})
} }
get totalUnreadCount() { get totalUnreadCount() {
return computed(() => { return computed(() => {
if (!this.notificationStore) return 0 // Not initialized yet
return Array.from(this.peers.value.values()) return Array.from(this.peers.value.values())
.reduce((total, peer) => total + peer.unreadCount, 0) .reduce((total, peer) => {
const messages = this.getMessages(peer.pubkey)
const unreadCount = this.getNotificationStore().getUnreadCount(peer.pubkey, messages)
return total + unreadCount
}, 0)
}) })
} }
get isReady() { get isReady() {
@ -123,7 +193,13 @@ export class ChatService extends BaseService {
} }
// Get peer by pubkey // Get peer by pubkey
getPeer(pubkey: string): ChatPeer | undefined { getPeer(pubkey: string): ChatPeer | undefined {
return this.peers.value.get(pubkey) const peer = this.peers.value.get(pubkey)
if (peer && this.notificationStore) {
// Update unread count from notification store (only if store is initialized)
const messages = this.getMessages(pubkey)
peer.unreadCount = this.getNotificationStore().getUnreadCount(pubkey, messages)
}
return peer
} }
// Add or update a peer // Add or update a peer
addPeer(pubkey: string, name?: string): ChatPeer { addPeer(pubkey: string, name?: string): ChatPeer {
@ -133,7 +209,9 @@ export class ChatService extends BaseService {
pubkey, pubkey,
name: name || `User ${pubkey.slice(0, 8)}`, name: name || `User ${pubkey.slice(0, 8)}`,
unreadCount: 0, unreadCount: 0,
lastSeen: Date.now() lastSent: 0,
lastReceived: 0,
lastChecked: 0
} }
this.peers.value.set(pubkey, peer) this.peers.value.set(pubkey, peer)
this.savePeersToStorage() this.savePeersToStorage()
@ -153,7 +231,7 @@ export class ChatService extends BaseService {
// Avoid duplicates // Avoid duplicates
if (!peerMessages.some(m => m.id === message.id)) { if (!peerMessages.some(m => m.id === message.id)) {
peerMessages.push(message) peerMessages.push(message)
// Sort by timestamp // Sort by timestamp (ascending - chronological order within conversation)
peerMessages.sort((a, b) => a.created_at - b.created_at) peerMessages.sort((a, b) => a.created_at - b.created_at)
// Limit message count // Limit message count
if (peerMessages.length > this.config.maxMessages) { if (peerMessages.length > this.config.maxMessages) {
@ -162,42 +240,95 @@ export class ChatService extends BaseService {
// Update peer info // Update peer info
const peer = this.addPeer(peerPubkey) const peer = this.addPeer(peerPubkey)
peer.lastMessage = message peer.lastMessage = message
peer.lastSeen = Date.now()
// Update unread count if message is not sent by us // Update lastSent or lastReceived based on message direction (Coracle pattern)
if (!message.sent) { if (message.sent) {
this.updateUnreadCount(peerPubkey, message) peer.lastSent = Math.max(peer.lastSent, message.created_at)
} else {
peer.lastReceived = Math.max(peer.lastReceived, message.created_at)
} }
// Update unread count from notification store (only if store is initialized)
const messages = this.getMessages(peerPubkey)
const unreadCount = this.notificationStore
? this.getNotificationStore().getUnreadCount(peerPubkey, messages)
: 0
peer.unreadCount = unreadCount
// Save updated peer data
this.savePeersToStorage()
// Emit events // Emit events
const eventType = message.sent ? 'chat:message-sent' : 'chat:message-received' const eventType = message.sent ? 'chat:message-sent' : 'chat:message-received'
eventBus.emit(eventType, { message, peerPubkey }, 'chat-service') eventBus.emit(eventType, { message, peerPubkey }, 'chat-service')
// Emit unread count change if message is not sent by us
if (!message.sent) {
eventBus.emit('chat:unread-count-changed', {
peerPubkey,
count: unreadCount,
totalUnread: this.totalUnreadCount.value
}, 'chat-service')
}
} }
} }
// Mark messages as read for a peer // Mark messages as read for a peer
markAsRead(peerPubkey: string): void { markAsRead(peerPubkey: string, timestamp?: number): void {
const peer = this.peers.value.get(peerPubkey) const peer = this.peers.value.get(peerPubkey)
if (peer && peer.unreadCount > 0) { if (peer) {
peer.unreadCount = 0 const ts = timestamp || Math.floor(Date.now() / 1000)
// Save unread state
const unreadData: UnreadMessageData = { // Update lastChecked timestamp (Coracle pattern)
lastReadTimestamp: Date.now(), const oldChecked = peer.lastChecked
unreadCount: 0, peer.lastChecked = Math.max(peer.lastChecked, ts)
processedMessageIds: new Set()
// Use notification store to mark as read
this.getNotificationStore().markChatAsRead(peerPubkey, timestamp)
// Update peer unread count
const messages = this.getMessages(peerPubkey)
const oldUnreadCount = peer.unreadCount
peer.unreadCount = this.getNotificationStore().getUnreadCount(peerPubkey, messages)
// Only save if something actually changed (prevent unnecessary reactivity)
if (oldChecked !== peer.lastChecked || oldUnreadCount !== peer.unreadCount) {
this.savePeersToStorage()
}
// Emit event only if unread count changed
if (oldUnreadCount !== peer.unreadCount) {
eventBus.emit('chat:unread-count-changed', {
peerPubkey,
count: peer.unreadCount,
totalUnread: this.totalUnreadCount.value
}, 'chat-service')
} }
this.saveUnreadData(peerPubkey, unreadData)
eventBus.emit('chat:unread-count-changed', {
peerPubkey,
count: 0,
totalUnread: this.totalUnreadCount.value
}, 'chat-service')
} }
} }
// Mark all chats as read
markAllChatsAsRead(): void {
this.getNotificationStore().markAllChatsAsRead()
// Update all peers' unread counts
Array.from(this.peers.value.values()).forEach(peer => {
const messages = this.getMessages(peer.pubkey)
peer.unreadCount = this.getNotificationStore().getUnreadCount(peer.pubkey, messages)
})
// Emit event
eventBus.emit('chat:unread-count-changed', {
peerPubkey: '*',
count: 0,
totalUnread: 0
}, 'chat-service')
}
// Refresh peers from API // Refresh peers from API
async refreshPeers(): Promise<void> { async refreshPeers(): Promise<void> {
// Check if we should trigger full initialization // Check if we should trigger full initialization
// Removed dual auth import // Removed dual auth import
const hasAuth = this.authService?.user?.value?.pubkey const hasAuth = this.authService?.user?.value?.pubkey
if (!this.isFullyInitialized && hasAuth) { if (!this.isFullyInitialized && hasAuth) {
console.log('💬 Refresh peers triggered full initialization')
await this.completeInitialization() await this.completeInitialization()
} }
return this.loadPeersFromAPI() return this.loadPeersFromAPI()
@ -252,8 +383,7 @@ export class ChatService extends BaseService {
// Add to local messages immediately // Add to local messages immediately
this.addMessage(peerPubkey, message) this.addMessage(peerPubkey, message)
// Publish to Nostr relays // Publish to Nostr relays
const result = await relayHub.publishEvent(signedEvent) await relayHub.publishEvent(signedEvent)
console.log('Message published to relays:', { success: result.success, total: result.total })
} catch (error) { } catch (error) {
console.error('Failed to send message:', error) console.error('Failed to send message:', error)
throw error throw error
@ -272,42 +402,6 @@ export class ChatService extends BaseService {
return bytes return bytes
} }
private updateUnreadCount(peerPubkey: string, message: ChatMessage): void {
const unreadData = this.getUnreadData(peerPubkey)
if (!unreadData.processedMessageIds.has(message.id)) {
unreadData.processedMessageIds.add(message.id)
unreadData.unreadCount++
const peer = this.peers.value.get(peerPubkey)
if (peer) {
peer.unreadCount = unreadData.unreadCount
this.savePeersToStorage()
}
this.saveUnreadData(peerPubkey, unreadData)
eventBus.emit('chat:unread-count-changed', {
peerPubkey,
count: unreadData.unreadCount,
totalUnread: this.totalUnreadCount.value
}, 'chat-service')
}
}
private getUnreadData(peerPubkey: string): UnreadMessageData {
const data = this.storageService.getUserData(`chat-unread-messages-${peerPubkey}`, {
lastReadTimestamp: 0,
unreadCount: 0,
processedMessageIds: []
})
return {
...data,
processedMessageIds: new Set(data.processedMessageIds || [])
}
}
private saveUnreadData(peerPubkey: string, data: UnreadMessageData): void {
const serializable = {
...data,
processedMessageIds: Array.from(data.processedMessageIds)
}
this.storageService.setUserData(`chat-unread-messages-${peerPubkey}`, serializable)
}
// Load peers from API // Load peers from API
async loadPeersFromAPI(): Promise<void> { async loadPeersFromAPI(): Promise<void> {
try { try {
@ -316,9 +410,15 @@ export class ChatService extends BaseService {
console.warn('💬 No authentication token found for loading peers from API') console.warn('💬 No authentication token found for loading peers from API')
throw new Error('No authentication token found') throw new Error('No authentication token found')
} }
// Get current user pubkey to exclude from peers
const currentUserPubkey = this.authService?.user?.value?.pubkey
if (!currentUserPubkey) {
console.warn('💬 No current user pubkey available')
}
const API_BASE_URL = config.api.baseUrl || 'http://localhost:5006' const API_BASE_URL = config.api.baseUrl || 'http://localhost:5006'
console.log('💬 Loading peers from API:', `${API_BASE_URL}/api/v1/auth/nostr/pubkeys`) const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/pubkeys`, {
const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/pubkeys`, {
headers: { headers: {
'Authorization': `Bearer ${authToken}`, 'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -330,7 +430,6 @@ export class ChatService extends BaseService {
throw new Error(`Failed to load peers: ${response.status} - ${errorText}`) throw new Error(`Failed to load peers: ${response.status} - ${errorText}`)
} }
const data = await response.json() const data = await response.json()
console.log('💬 API returned', data?.length || 0, 'peers')
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
console.warn('💬 Invalid API response format - expected array, got:', typeof data) console.warn('💬 Invalid API response format - expected array, got:', typeof data)
return return
@ -341,17 +440,35 @@ export class ChatService extends BaseService {
console.warn('💬 Skipping peer without pubkey:', peer) console.warn('💬 Skipping peer without pubkey:', peer)
return return
} }
const chatPeer: ChatPeer = {
pubkey: peer.pubkey, // CRITICAL: Skip current user - you can't chat with yourself!
name: peer.username || `User ${peer.pubkey.slice(0, 8)}`, if (currentUserPubkey && peer.pubkey === currentUserPubkey) {
unreadCount: 0, return
lastSeen: Date.now() }
// Check if peer already exists to preserve message history timestamps
const existingPeer = this.peers.value.get(peer.pubkey)
if (existingPeer) {
// Update name only if provided
if (peer.username && peer.username !== existingPeer.name) {
existingPeer.name = peer.username
}
} else {
// Create new peer with all required fields
const chatPeer: ChatPeer = {
pubkey: peer.pubkey,
name: peer.username || `User ${peer.pubkey.slice(0, 8)}`,
unreadCount: 0,
lastSent: 0,
lastReceived: 0,
lastChecked: 0
}
this.peers.value.set(peer.pubkey, chatPeer)
} }
this.peers.value.set(peer.pubkey, chatPeer)
}) })
// Save to storage // Save to storage
this.savePeersToStorage() this.savePeersToStorage()
console.log(`✅ Loaded ${data.length} peers from API, total peers now: ${this.peers.value.size}`)
} catch (error) { } catch (error) {
console.error('❌ Failed to load peers from API:', error) console.error('❌ Failed to load peers from API:', error)
// Don't re-throw - peers from storage are still available // Don't re-throw - peers from storage are still available
@ -366,9 +483,15 @@ export class ChatService extends BaseService {
} }
try { try {
const peersArray = this.storageService.getUserData('chat-peers', []) as ChatPeer[] const peersArray = this.storageService.getUserData('chat-peers', []) as ChatPeer[]
console.log('💬 Loading', peersArray.length, 'peers from storage')
peersArray.forEach(peer => { peersArray.forEach(peer => {
this.peers.value.set(peer.pubkey, peer) // Migrate old peer structure to new structure with required fields
const migratedPeer: ChatPeer = {
...peer,
lastSent: peer.lastSent ?? 0,
lastReceived: peer.lastReceived ?? 0,
lastChecked: peer.lastChecked ?? 0
}
this.peers.value.set(peer.pubkey, migratedPeer)
}) })
} catch (error) { } catch (error) {
console.warn('💬 Failed to load peers from storage:', error) console.warn('💬 Failed to load peers from storage:', error)
@ -396,10 +519,8 @@ export class ChatService extends BaseService {
} }
const peerPubkeys = Array.from(this.peers.value.keys()) const peerPubkeys = Array.from(this.peers.value.keys())
if (peerPubkeys.length === 0) { if (peerPubkeys.length === 0) {
console.log('No peers to load message history for') return
return
} }
console.log('Loading message history for', peerPubkeys.length, 'peers')
// Query historical messages (kind 4) to/from known peers // Query historical messages (kind 4) to/from known peers
// We need separate queries for sent vs received messages due to different tagging // We need separate queries for sent vs received messages due to different tagging
const receivedEvents = await this.relayHub.queryEvents([ const receivedEvents = await this.relayHub.queryEvents([
@ -420,7 +541,27 @@ export class ChatService extends BaseService {
]) ])
const events = [...receivedEvents, ...sentEvents] const events = [...receivedEvents, ...sentEvents]
.sort((a, b) => a.created_at - b.created_at) // Sort by timestamp .sort((a, b) => a.created_at - b.created_at) // Sort by timestamp
console.log('Found', events.length, 'historical messages:', receivedEvents.length, 'received,', sentEvents.length, 'sent')
// CRITICAL: First pass - create all peers from message events BEFORE loading from API
const uniquePeerPubkeys = new Set<string>()
for (const event of events) {
const isFromUs = event.pubkey === userPubkey
const peerPubkey = isFromUs
? event.tags.find((tag: string[]) => tag[0] === 'p')?.[1]
: event.pubkey
if (peerPubkey && peerPubkey !== userPubkey) {
uniquePeerPubkeys.add(peerPubkey)
}
}
// Create peers from actual message senders
for (const peerPubkey of uniquePeerPubkeys) {
if (!this.peers.value.has(peerPubkey)) {
this.addPeer(peerPubkey)
}
}
// Process historical messages // Process historical messages
for (const event of events) { for (const event of events) {
try { try {
@ -429,6 +570,7 @@ export class ChatService extends BaseService {
? event.tags.find((tag: string[]) => tag[0] === 'p')?.[1] // Get recipient from tag ? event.tags.find((tag: string[]) => tag[0] === 'p')?.[1] // Get recipient from tag
: event.pubkey // Sender is the peer : event.pubkey // Sender is the peer
if (!peerPubkey || peerPubkey === userPubkey) continue if (!peerPubkey || peerPubkey === userPubkey) continue
// Decrypt the message // Decrypt the message
const decryptedContent = await nip04.decrypt(userPrivkey, peerPubkey, event.content) const decryptedContent = await nip04.decrypt(userPrivkey, peerPubkey, event.content)
// Create a chat message // Create a chat message
@ -439,13 +581,13 @@ export class ChatService extends BaseService {
sent: isFromUs, sent: isFromUs,
pubkey: event.pubkey pubkey: event.pubkey
} }
// Add the message (will avoid duplicates) // Add the message (will avoid duplicates)
this.addMessage(peerPubkey, message) this.addMessage(peerPubkey, message)
} catch (error) { } catch (error) {
console.error('Failed to decrypt historical message:', error) console.error('Failed to decrypt historical message:', error)
} }
} }
console.log('Message history loaded successfully')
} catch (error) { } catch (error) {
console.error('Failed to load message history:', error) console.error('Failed to load message history:', error)
} }
@ -473,7 +615,6 @@ export class ChatService extends BaseService {
console.warn('💬 RelayHub not connected, waiting for connection...') console.warn('💬 RelayHub not connected, waiting for connection...')
// Listen for connection event // Listen for connection event
this.relayHub.on('connected', () => { this.relayHub.on('connected', () => {
console.log('💬 RelayHub connected, setting up message subscription...')
this.setupMessageSubscription() this.setupMessageSubscription()
}) })
// Also retry after timeout in case event is missed // Also retry after timeout in case event is missed
@ -502,10 +643,8 @@ export class ChatService extends BaseService {
await this.processIncomingMessage(event) await this.processIncomingMessage(event)
}, },
onEose: () => { onEose: () => {
console.log('💬 Chat message subscription EOSE received')
} }
}) })
console.log('💬 Chat message subscription set up successfully for pubkey:', userPubkey.substring(0, 10) + '...')
} catch (error) { } catch (error) {
console.error('💬 Failed to setup message subscription:', error) console.error('💬 Failed to setup message subscription:', error)
// Retry after delay // Retry after delay
@ -590,7 +729,6 @@ export class ChatService extends BaseService {
// Forward to market handler // Forward to market handler
if (this.marketMessageHandler) { if (this.marketMessageHandler) {
await this.marketMessageHandler(event) await this.marketMessageHandler(event)
console.log('💬 Market message forwarded to market handler and will also be added to chat')
} else { } else {
console.warn('Market message handler not available, message will be treated as chat') console.warn('Market message handler not available, message will be treated as chat')
} }
@ -633,7 +771,6 @@ export class ChatService extends BaseService {
this.addPeer(senderPubkey) this.addPeer(senderPubkey)
// Add the message // Add the message
this.addMessage(senderPubkey, message) this.addMessage(senderPubkey, message)
console.log('Received encrypted chat message from:', senderPubkey.slice(0, 8))
} }
} catch (error) { } catch (error) {
console.error('Failed to process incoming message:', error) console.error('Failed to process incoming message:', error)

View file

@ -0,0 +1,202 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { StorageService } from '@/core/services/StorageService'
/**
* Chat Notification Store
*
* Implements Coracle-inspired path-based notification tracking with wildcard support.
* Uses timestamps instead of boolean flags for flexible "mark as read up to X time" behavior.
*
* Path patterns:
* - 'chat/*' - All chat notifications
* - 'chat/{pubkey}' - Specific chat conversation
* - '*' - Global mark all as read
*/
const STORAGE_KEY = 'chat-notifications-checked'
export const useChatNotificationStore = defineStore('chat-notifications', () => {
// Inject storage service for user-scoped persistence
const storageService = injectService<StorageService>(SERVICE_TOKENS.STORAGE_SERVICE)
// State: path -> timestamp mappings
const checked = ref<Record<string, number>>({})
/**
* Load notification state from storage
*/
const loadFromStorage = () => {
if (!storageService) {
console.warn('📢 Cannot load chat notifications: StorageService not available')
return
}
const stored = storageService.getUserData<Record<string, number>>(STORAGE_KEY, {})
checked.value = stored || {}
}
// Debounce timer for storage writes
let saveDebounce: ReturnType<typeof setTimeout> | undefined
/**
* Save notification state to storage (debounced)
*/
const saveToStorage = () => {
if (!storageService) return
// Clear existing debounce timer
if (saveDebounce !== undefined) {
clearTimeout(saveDebounce)
}
// Debounce writes by 2 seconds (Snort pattern)
saveDebounce = setTimeout(() => {
storageService.setUserData(STORAGE_KEY, checked.value)
saveDebounce = undefined
}, 2000)
}
/**
* Get the "seen at" timestamp for a given path and event timestamp
*
* Implements Coracle's wildcard matching logic:
* 1. Check direct path match
* 2. Check wildcard pattern (e.g., 'chat/*' for 'chat/abc123')
* 3. Check global wildcard ('*')
*
* @param path - Notification path (e.g., 'chat/pubkey123')
* @param eventTimestamp - Timestamp of the event to check
* @returns The max timestamp if event has been seen, 0 otherwise
*/
const getSeenAt = (path: string, eventTimestamp: number): number => {
const directMatch = checked.value[path] || 0
// Extract wildcard pattern (e.g., 'chat/*' from 'chat/abc123')
const pathParts = path.split('/')
const wildcardMatch = pathParts.length > 1
? (checked.value[`${pathParts[0]}/*`] || 0)
: 0
const globalMatch = checked.value['*'] || 0
// Get maximum timestamp from all matches
const maxTimestamp = Math.max(directMatch, wildcardMatch, globalMatch)
// Return maxTimestamp if event has been seen, 0 otherwise
return maxTimestamp >= eventTimestamp ? maxTimestamp : 0
}
/**
* Check if a message/event has been seen
*
* @param path - Notification path
* @param eventTimestamp - Event timestamp to check
* @returns True if the event has been marked as read
*/
const isSeen = (path: string, eventTimestamp: number): boolean => {
return getSeenAt(path, eventTimestamp) > 0
}
/**
* Mark a path as checked/read
*
* @param path - Notification path to mark as read
* @param timestamp - Optional timestamp (defaults to now)
*/
const setChecked = (path: string, timestamp?: number) => {
const ts = timestamp || Math.floor(Date.now() / 1000)
checked.value[path] = ts
saveToStorage()
}
/**
* Mark all chat messages as read
*/
const markAllChatsAsRead = () => {
setChecked('chat/*')
}
/**
* Mark a specific chat conversation as read
*
* @param peerPubkey - Pubkey of the chat peer
* @param timestamp - Optional timestamp (defaults to now)
*/
const markChatAsRead = (peerPubkey: string, timestamp?: number) => {
setChecked(`chat/${peerPubkey}`, timestamp)
}
/**
* Mark everything as read (global)
*/
const markAllAsRead = () => {
setChecked('*')
}
/**
* Get unread count for a specific chat
*
* @param peerPubkey - Pubkey of the chat peer
* @param messages - Array of chat messages with created_at timestamps
* @returns Number of unread messages
*/
const getUnreadCount = (peerPubkey: string, messages: Array<{ created_at: number; sent: boolean }>): number => {
const path = `chat/${peerPubkey}`
const receivedMessages = messages.filter(msg => !msg.sent)
const unseenMessages = receivedMessages.filter(msg => !isSeen(path, msg.created_at))
return unseenMessages.length
}
/**
* Force immediate save (for critical operations or before unload)
*/
const saveImmediately = () => {
if (!storageService) return
// Cancel any pending debounced save
if (saveDebounce !== undefined) {
clearTimeout(saveDebounce)
saveDebounce = undefined
}
// Save immediately
storageService.setUserData(STORAGE_KEY, checked.value)
}
/**
* Clear all notification state
*/
const clearAll = () => {
checked.value = {}
saveImmediately() // Clear immediately
}
// Initialize from storage
loadFromStorage()
// Save immediately before page unload (ensure no data loss)
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', saveImmediately)
}
return {
// State
checked: computed(() => checked.value),
// Methods
getSeenAt,
isSeen,
setChecked,
markAllChatsAsRead,
markChatAsRead,
markAllAsRead,
getUnreadCount,
clearAll,
saveImmediately,
loadFromStorage, // Export for explicit reload if needed
}
})

View file

@ -13,7 +13,9 @@ export interface ChatPeer {
name?: string name?: string
lastMessage?: ChatMessage lastMessage?: ChatMessage
unreadCount: number unreadCount: number
lastSeen: number lastSent: number // Timestamp of last message YOU sent
lastReceived: number // Timestamp of last message you RECEIVED
lastChecked: number // Timestamp when you last viewed the conversation
} }
export interface NostrRelayConfig { export interface NostrRelayConfig {
@ -22,18 +24,17 @@ export interface NostrRelayConfig {
write?: boolean write?: boolean
} }
export interface UnreadMessageData { export interface ChatNotificationConfig {
lastReadTimestamp: number enabled: boolean
unreadCount: number soundEnabled: boolean
processedMessageIds: Set<string> wildcardSupport: boolean
} }
export interface ChatConfig { export interface ChatConfig {
maxMessages: number maxMessages: number
autoScroll: boolean autoScroll: boolean
showTimestamps: boolean showTimestamps: boolean
notificationsEnabled: boolean notifications?: ChatNotificationConfig
soundEnabled: boolean
} }
// Events emitted by chat module // Events emitted by chat module