Merge branch 'improve-chat'
This commit is contained in:
commit
08b172ab34
8 changed files with 535 additions and 119 deletions
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
@ -81,19 +85,20 @@ export function useChat() {
|
||||||
refreshPeersError: refreshPeersOp.error,
|
refreshPeersError: refreshPeersOp.error,
|
||||||
isLoading: computed(() => asyncOps.isAnyLoading()),
|
isLoading: computed(() => asyncOps.isAnyLoading()),
|
||||||
error: computed(() => sendMessageOp.error.value || refreshPeersOp.error.value),
|
error: computed(() => sendMessageOp.error.value || refreshPeersOp.error.value),
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
peers,
|
peers,
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
isReady,
|
isReady,
|
||||||
currentMessages,
|
currentMessages,
|
||||||
currentPeer,
|
currentPeer,
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
selectPeer,
|
selectPeer,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
addPeer,
|
addPeer,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
|
markAllChatsAsRead,
|
||||||
refreshPeers
|
refreshPeers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
77
src/modules/chat/composables/useNotifications.ts
Normal file
77
src/modules/chat/composables/useNotifications.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
@ -412,7 +533,7 @@ export class ChatService extends BaseService {
|
||||||
])
|
])
|
||||||
const sentEvents = await this.relayHub.queryEvents([
|
const sentEvents = await this.relayHub.queryEvents([
|
||||||
{
|
{
|
||||||
kinds: [4],
|
kinds: [4],
|
||||||
authors: [userPubkey], // Messages from us
|
authors: [userPubkey], // Messages from us
|
||||||
'#p': peerPubkeys, // Messages tagged to peers
|
'#p': peerPubkeys, // Messages tagged to peers
|
||||||
limit: 100
|
limit: 100
|
||||||
|
|
@ -420,15 +541,36 @@ 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 {
|
||||||
const isFromUs = event.pubkey === userPubkey
|
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.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)
|
||||||
|
|
|
||||||
202
src/modules/chat/stores/notification.ts
Normal file
202
src/modules/chat/stores/notification.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue