diff --git a/src/app.config.ts b/src/app.config.ts index a60693e..8060762 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -54,7 +54,12 @@ export const appConfig: AppConfig = { config: { maxMessages: 500, autoScroll: true, - showTimestamps: true + showTimestamps: true, + notifications: { + enabled: true, + soundEnabled: false, + wildcardSupport: true + } } }, events: { diff --git a/src/modules/chat/components/ChatComponent.vue b/src/modules/chat/components/ChatComponent.vue index 7e2c27a..f893e2d 100644 --- a/src/modules/chat/components/ChatComponent.vue +++ b/src/modules/chat/components/ChatComponent.vue @@ -415,22 +415,8 @@ const currentMessages = computed(() => { return chat.currentMessages.value }) -// Sort peers by unread count and name -const sortedPeers = computed(() => { - 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 -}) +// NOTE: peers is already sorted correctly by the chat service (by activity: lastSent/lastReceived) +// We use it directly without re-sorting here // Fuzzy search for peers // This integrates the useFuzzySearch composable to provide intelligent search functionality @@ -441,7 +427,7 @@ const { isSearching, resultCount, clearSearch -} = useFuzzySearch(sortedPeers, { +} = useFuzzySearch(peers, { fuseOptions: { keys: [ { name: 'name', weight: 0.7 }, // Name has higher weight for better UX diff --git a/src/modules/chat/composables/useChat.ts b/src/modules/chat/composables/useChat.ts index c0a3136..6bc6578 100644 --- a/src/modules/chat/composables/useChat.ts +++ b/src/modules/chat/composables/useChat.ts @@ -60,8 +60,12 @@ export function useChat() { return chatService.addPeer(pubkey, name) } - const markAsRead = (peerPubkey: string) => { - chatService.markAsRead(peerPubkey) + const markAsRead = (peerPubkey: string, timestamp?: number) => { + chatService.markAsRead(peerPubkey, timestamp) + } + + const markAllChatsAsRead = () => { + chatService.markAllChatsAsRead() } const refreshPeers = async () => { @@ -81,19 +85,20 @@ export function useChat() { refreshPeersError: refreshPeersOp.error, isLoading: computed(() => asyncOps.isAnyLoading()), error: computed(() => sendMessageOp.error.value || refreshPeersOp.error.value), - + // Computed peers, totalUnreadCount, isReady, currentMessages, currentPeer, - + // Methods selectPeer, sendMessage, addPeer, markAsRead, + markAllChatsAsRead, refreshPeers } } \ No newline at end of file diff --git a/src/modules/chat/composables/useNotifications.ts b/src/modules/chat/composables/useNotifications.ts new file mode 100644 index 0000000..97f0fd7 --- /dev/null +++ b/src/modules/chat/composables/useNotifications.ts @@ -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, + } +} diff --git a/src/modules/chat/index.ts b/src/modules/chat/index.ts index 831732a..b3ec669 100644 --- a/src/modules/chat/index.ts +++ b/src/modules/chat/index.ts @@ -26,8 +26,11 @@ export const chatModule: ModulePlugin = { maxMessages: 500, autoScroll: true, showTimestamps: true, - notificationsEnabled: true, - soundEnabled: false, + notifications: { + enabled: true, + soundEnabled: false, + wildcardSupport: true + }, ...options?.config } diff --git a/src/modules/chat/services/chat-service.ts b/src/modules/chat/services/chat-service.ts index 503ddc4..37cb14c 100644 --- a/src/modules/chat/services/chat-service.ts +++ b/src/modules/chat/services/chat-service.ts @@ -2,9 +2,10 @@ import { ref, computed } from 'vue' import { eventBus } from '@/core/event-bus' import { BaseService } from '@/core/base/BaseService' 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 { config } from '@/lib/config' +import { useChatNotificationStore } from '../stores/notification' export class ChatService extends BaseService { // Service metadata protected readonly metadata = { @@ -21,20 +22,35 @@ export class ChatService extends BaseService { private visibilityUnsubscribe?: () => void private isFullyInitialized = false private authCheckInterval?: ReturnType + private notificationStore?: ReturnType + constructor(config: ChatConfig) { super() 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 setMarketMessageHandler(handler: (event: any) => Promise) { this.marketMessageHandler = handler } + + /** + * Get the notification store, ensuring it's initialized + * CRITICAL: This must only be called after onInitialize() has run + */ + private getNotificationStore(): ReturnType { + 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) */ protected async onInitialize(): Promise { this.debug('Chat service onInitialize called') + // Check both injected auth service AND global auth composable // Removed dual auth import const hasAuthService = this.authService?.user?.value?.pubkey @@ -83,6 +99,13 @@ export class ChatService extends BaseService { return } 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 this.loadPeersFromStorage() // Load peers from API @@ -106,12 +129,59 @@ export class ChatService extends BaseService { } // Computed properties 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() { return computed(() => { + if (!this.notificationStore) return 0 // Not initialized yet 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() { @@ -123,7 +193,13 @@ export class ChatService extends BaseService { } // Get peer by pubkey 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 addPeer(pubkey: string, name?: string): ChatPeer { @@ -133,7 +209,9 @@ export class ChatService extends BaseService { pubkey, name: name || `User ${pubkey.slice(0, 8)}`, unreadCount: 0, - lastSeen: Date.now() + lastSent: 0, + lastReceived: 0, + lastChecked: 0 } this.peers.value.set(pubkey, peer) this.savePeersToStorage() @@ -153,7 +231,7 @@ export class ChatService extends BaseService { // Avoid duplicates if (!peerMessages.some(m => m.id === message.id)) { peerMessages.push(message) - // Sort by timestamp + // Sort by timestamp (ascending - chronological order within conversation) peerMessages.sort((a, b) => a.created_at - b.created_at) // Limit message count if (peerMessages.length > this.config.maxMessages) { @@ -162,42 +240,95 @@ export class ChatService extends BaseService { // Update peer info const peer = this.addPeer(peerPubkey) peer.lastMessage = message - peer.lastSeen = Date.now() - // Update unread count if message is not sent by us - if (!message.sent) { - this.updateUnreadCount(peerPubkey, message) + + // Update lastSent or lastReceived based on message direction (Coracle pattern) + if (message.sent) { + 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 const eventType = message.sent ? 'chat:message-sent' : 'chat:message-received' 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 - markAsRead(peerPubkey: string): void { + markAsRead(peerPubkey: string, timestamp?: number): void { const peer = this.peers.value.get(peerPubkey) - if (peer && peer.unreadCount > 0) { - peer.unreadCount = 0 - // Save unread state - const unreadData: UnreadMessageData = { - lastReadTimestamp: Date.now(), - unreadCount: 0, - processedMessageIds: new Set() + if (peer) { + const ts = timestamp || Math.floor(Date.now() / 1000) + + // Update lastChecked timestamp (Coracle pattern) + const oldChecked = peer.lastChecked + peer.lastChecked = Math.max(peer.lastChecked, ts) + + // 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 async refreshPeers(): Promise { // Check if we should trigger full initialization // Removed dual auth import const hasAuth = this.authService?.user?.value?.pubkey if (!this.isFullyInitialized && hasAuth) { - console.log('💬 Refresh peers triggered full initialization') await this.completeInitialization() } return this.loadPeersFromAPI() @@ -252,8 +383,7 @@ export class ChatService extends BaseService { // Add to local messages immediately this.addMessage(peerPubkey, message) // Publish to Nostr relays - const result = await relayHub.publishEvent(signedEvent) - console.log('Message published to relays:', { success: result.success, total: result.total }) + await relayHub.publishEvent(signedEvent) } catch (error) { console.error('Failed to send message:', error) throw error @@ -272,42 +402,6 @@ export class ChatService extends BaseService { 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 async loadPeersFromAPI(): Promise { try { @@ -316,9 +410,15 @@ export class ChatService extends BaseService { console.warn('💬 No authentication token found for loading peers from API') 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' - 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: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' @@ -330,7 +430,6 @@ export class ChatService extends BaseService { throw new Error(`Failed to load peers: ${response.status} - ${errorText}`) } const data = await response.json() - console.log('💬 API returned', data?.length || 0, 'peers') if (!Array.isArray(data)) { console.warn('💬 Invalid API response format - expected array, got:', typeof data) return @@ -341,17 +440,35 @@ export class ChatService extends BaseService { console.warn('💬 Skipping peer without pubkey:', peer) return } - const chatPeer: ChatPeer = { - pubkey: peer.pubkey, - name: peer.username || `User ${peer.pubkey.slice(0, 8)}`, - unreadCount: 0, - lastSeen: Date.now() + + // CRITICAL: Skip current user - you can't chat with yourself! + if (currentUserPubkey && peer.pubkey === currentUserPubkey) { + return + } + + // 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 this.savePeersToStorage() - console.log(`✅ Loaded ${data.length} peers from API, total peers now: ${this.peers.value.size}`) } catch (error) { console.error('❌ Failed to load peers from API:', error) // Don't re-throw - peers from storage are still available @@ -366,9 +483,15 @@ export class ChatService extends BaseService { } try { const peersArray = this.storageService.getUserData('chat-peers', []) as ChatPeer[] - console.log('💬 Loading', peersArray.length, 'peers from storage') 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) { 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()) 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 // We need separate queries for sent vs received messages due to different tagging const receivedEvents = await this.relayHub.queryEvents([ @@ -412,7 +533,7 @@ export class ChatService extends BaseService { ]) const sentEvents = await this.relayHub.queryEvents([ { - kinds: [4], + kinds: [4], authors: [userPubkey], // Messages from us '#p': peerPubkeys, // Messages tagged to peers limit: 100 @@ -420,15 +541,36 @@ export class ChatService extends BaseService { ]) const events = [...receivedEvents, ...sentEvents] .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() + 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 for (const event of events) { try { const isFromUs = event.pubkey === userPubkey - const peerPubkey = isFromUs + const peerPubkey = isFromUs ? event.tags.find((tag: string[]) => tag[0] === 'p')?.[1] // Get recipient from tag : event.pubkey // Sender is the peer if (!peerPubkey || peerPubkey === userPubkey) continue + // Decrypt the message const decryptedContent = await nip04.decrypt(userPrivkey, peerPubkey, event.content) // Create a chat message @@ -439,13 +581,13 @@ export class ChatService extends BaseService { sent: isFromUs, pubkey: event.pubkey } + // Add the message (will avoid duplicates) this.addMessage(peerPubkey, message) } catch (error) { console.error('Failed to decrypt historical message:', error) } } - console.log('Message history loaded successfully') } catch (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...') // Listen for connection event this.relayHub.on('connected', () => { - console.log('💬 RelayHub connected, setting up message subscription...') this.setupMessageSubscription() }) // Also retry after timeout in case event is missed @@ -502,10 +643,8 @@ export class ChatService extends BaseService { await this.processIncomingMessage(event) }, onEose: () => { - console.log('💬 Chat message subscription EOSE received') } }) - console.log('💬 Chat message subscription set up successfully for pubkey:', userPubkey.substring(0, 10) + '...') } catch (error) { console.error('💬 Failed to setup message subscription:', error) // Retry after delay @@ -590,7 +729,6 @@ export class ChatService extends BaseService { // Forward to market handler if (this.marketMessageHandler) { await this.marketMessageHandler(event) - console.log('💬 Market message forwarded to market handler and will also be added to chat') } else { 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) // Add the message this.addMessage(senderPubkey, message) - console.log('Received encrypted chat message from:', senderPubkey.slice(0, 8)) } } catch (error) { console.error('Failed to process incoming message:', error) diff --git a/src/modules/chat/stores/notification.ts b/src/modules/chat/stores/notification.ts new file mode 100644 index 0000000..d0af785 --- /dev/null +++ b/src/modules/chat/stores/notification.ts @@ -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(SERVICE_TOKENS.STORAGE_SERVICE) + + + // State: path -> timestamp mappings + const checked = ref>({}) + + /** + * Load notification state from storage + */ + const loadFromStorage = () => { + if (!storageService) { + console.warn('📢 Cannot load chat notifications: StorageService not available') + return + } + + const stored = storageService.getUserData>(STORAGE_KEY, {}) + checked.value = stored || {} + } + + // Debounce timer for storage writes + let saveDebounce: ReturnType | 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 + } +}) diff --git a/src/modules/chat/types/index.ts b/src/modules/chat/types/index.ts index 37b23fa..404747a 100644 --- a/src/modules/chat/types/index.ts +++ b/src/modules/chat/types/index.ts @@ -13,7 +13,9 @@ export interface ChatPeer { name?: string lastMessage?: ChatMessage 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 { @@ -22,18 +24,17 @@ export interface NostrRelayConfig { write?: boolean } -export interface UnreadMessageData { - lastReadTimestamp: number - unreadCount: number - processedMessageIds: Set +export interface ChatNotificationConfig { + enabled: boolean + soundEnabled: boolean + wildcardSupport: boolean } export interface ChatConfig { maxMessages: number autoScroll: boolean showTimestamps: boolean - notificationsEnabled: boolean - soundEnabled: boolean + notifications?: ChatNotificationConfig } // Events emitted by chat module