feat: Add unread message notifications and tracking in Nostr chat

- Implement unread message indicators in the ChatComponent for both total unread messages and per-peer unread counts.
- Enhance the useNostrChat composable to manage unread message data, including saving and loading unread counts from localStorage.
- Introduce methods to mark messages as read and update unread counts dynamically as new messages are received.
- Refactor the message handling logic to ensure accurate tracking of unread messages based on the last read timestamp.
This commit is contained in:
padreug 2025-08-08 21:52:16 +02:00
parent aa3509d807
commit 5fa3fcf60f
2 changed files with 219 additions and 48 deletions

View file

@ -12,6 +12,10 @@
<Badge v-else variant="secondary" class="text-xs">
Disconnected
</Badge>
<!-- Total unread count -->
<Badge v-if="getTotalUnreadCount() > 0" variant="destructive" class="text-xs">
{{ getTotalUnreadCount() }} unread
</Badge>
</div>
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
@ -67,7 +71,7 @@
:key="peer.user_id"
@click="selectPeer(peer)"
:class="[
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors touch-manipulation',
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors touch-manipulation relative',
selectedPeer?.user_id === peer.user_id
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted active:bg-muted/80'
@ -85,6 +89,12 @@
{{ formatPubkey(peer.pubkey) }}
</p>
</div>
<!-- Unread message indicator -->
<div v-if="getUnreadCount(peer.pubkey) > 0" class="flex-shrink-0">
<Badge variant="destructive" class="h-6 w-6 rounded-full p-0 flex items-center justify-center text-xs font-bold">
{{ getUnreadCount(peer.pubkey) > 99 ? '99+' : getUnreadCount(peer.pubkey) }}
</Badge>
</div>
</div>
</div>
</ScrollArea>
@ -115,12 +125,18 @@
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<Badge v-if="isConnected" variant="default" class="text-xs">
Connected
</Badge>
<Badge v-else variant="secondary" class="text-xs">
Disconnected
</Badge>
<!-- Unread count for current peer -->
<Badge v-if="selectedPeer && getUnreadCount(selectedPeer.pubkey) > 0" variant="destructive" class="text-xs">
{{ getUnreadCount(selectedPeer.pubkey) }} unread
</Badge>
</div>
</div>
<!-- Messages -->
@ -181,6 +197,10 @@
<Badge v-else variant="secondary" class="text-xs">
Disconnected
</Badge>
<!-- Total unread count -->
<Badge v-if="getTotalUnreadCount() > 0" variant="destructive" class="text-xs">
{{ getTotalUnreadCount() }} unread
</Badge>
</div>
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
@ -238,7 +258,7 @@
:key="peer.user_id"
@click="selectPeer(peer)"
:class="[
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors',
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors relative',
selectedPeer?.user_id === peer.user_id
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
@ -256,6 +276,12 @@
{{ formatPubkey(peer.pubkey) }}
</p>
</div>
<!-- Unread message indicator -->
<div v-if="getUnreadCount(peer.pubkey) > 0" class="flex-shrink-0">
<Badge variant="destructive" class="h-6 w-6 rounded-full p-0 flex items-center justify-center text-xs font-bold">
{{ getUnreadCount(peer.pubkey) > 99 ? '99+' : getUnreadCount(peer.pubkey) }}
</Badge>
</div>
</div>
</div>
</ScrollArea>
@ -420,11 +446,16 @@ const goBackToPeers = () => {
const {
isConnected,
messages,
sendMessage: sendNostrMessage,
isLoggedIn,
connect,
disconnect,
subscribeToPeer,
onMessageAdded
sendMessage: sendNostrMessage,
onMessageAdded,
markMessagesAsRead,
getUnreadCount,
getAllUnreadCounts,
getTotalUnreadCount
} = useNostrChat()
// Computed
@ -491,6 +522,9 @@ const selectPeer = async (peer: Peer) => {
selectedPeer.value = peer
messageInput.value = ''
// Mark messages as read for this peer
markMessagesAsRead(peer.pubkey)
// On mobile, show chat view
if (isMobile.value) {
showChat.value = true

View file

@ -20,6 +20,136 @@ export interface NostrRelayConfig {
write?: boolean
}
// Add notification system for unread messages
interface UnreadMessageData {
lastReadTimestamp: number
unreadCount: number
}
const UNREAD_MESSAGES_KEY = 'nostr-chat-unread-messages'
// Get unread message data for a peer
const getUnreadData = (peerPubkey: string): UnreadMessageData => {
try {
const stored = localStorage.getItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`)
return stored ? JSON.parse(stored) : { lastReadTimestamp: 0, unreadCount: 0 }
} catch (error) {
console.warn('Failed to load unread data for peer:', peerPubkey, error)
return { lastReadTimestamp: 0, unreadCount: 0 }
}
}
// Save unread message data for a peer
const saveUnreadData = (peerPubkey: string, data: UnreadMessageData): void => {
try {
localStorage.setItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`, JSON.stringify(data))
} catch (error) {
console.warn('Failed to save unread data for peer:', peerPubkey, error)
}
}
export function useNostrChat() {
// State
const isConnected = ref(false)
const messages = ref<Map<string, ChatMessage[]>>(new Map())
const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null)
const processedMessageIds = ref(new Set<string>())
const onMessageAdded = ref<((peerPubkey: string) => void) | null>(null)
const pool = ref<SimplePool | null>(null)
// Reactive unread counts
const unreadCounts = ref<Map<string, number>>(new Map())
// Computed
const isLoggedIn = computed(() => !!currentUser.value)
// Get unread count for a peer
const getUnreadCount = (peerPubkey: string): number => {
return unreadCounts.value.get(peerPubkey) || 0
}
// Get all unread counts
const getAllUnreadCounts = (): Map<string, number> => {
return new Map(unreadCounts.value)
}
// Get total unread count across all peers
const getTotalUnreadCount = (): number => {
let total = 0
for (const count of unreadCounts.value.values()) {
total += count
}
return total
}
// Update unread count for a peer
const updateUnreadCount = (peerPubkey: string, count: number): void => {
if (count > 0) {
unreadCounts.value.set(peerPubkey, count)
} else {
unreadCounts.value.delete(peerPubkey)
}
// Force reactivity
unreadCounts.value = new Map(unreadCounts.value)
}
// Mark messages as read for a peer
const markMessagesAsRead = (peerPubkey: string): void => {
const currentTimestamp = Math.floor(Date.now() / 1000)
const unreadData = getUnreadData(peerPubkey)
// Update last read timestamp and reset unread count
const updatedData: UnreadMessageData = {
lastReadTimestamp: currentTimestamp,
unreadCount: 0
}
saveUnreadData(peerPubkey, updatedData)
updateUnreadCount(peerPubkey, 0)
}
// Load unread counts from localStorage
const loadUnreadCounts = (): void => {
try {
const keys = Object.keys(localStorage).filter(key =>
key.startsWith(`${UNREAD_MESSAGES_KEY}-`)
)
for (const key of keys) {
const peerPubkey = key.replace(`${UNREAD_MESSAGES_KEY}-`, '')
const unreadData = getUnreadData(peerPubkey)
if (unreadData.unreadCount > 0) {
unreadCounts.value.set(peerPubkey, unreadData.unreadCount)
}
}
} catch (error) {
console.warn('Failed to load unread counts from localStorage:', error)
}
}
// Initialize unread counts on startup
loadUnreadCounts()
// Clear all unread counts (for testing)
const clearAllUnreadCounts = (): void => {
unreadCounts.value.clear()
unreadCounts.value = new Map(unreadCounts.value)
// Also clear from localStorage
try {
const keys = Object.keys(localStorage).filter(key =>
key.startsWith(`${UNREAD_MESSAGES_KEY}-`)
)
for (const key of keys) {
localStorage.removeItem(key)
}
} catch (error) {
console.warn('Failed to clear unread counts from localStorage:', error)
}
}
// Get relays from config - requires VITE_NOSTR_RELAYS to be set
const getRelays = (): NostrRelayConfig[] => {
const configuredRelays = config.nostr.relays
@ -30,21 +160,6 @@ const getRelays = (): NostrRelayConfig[] => {
return configuredRelays.map((url: string) => ({ url, read: true, write: true }))
}
export function useNostrChat() {
// State
const isConnected = ref(false)
const messages = ref<Map<string, ChatMessage[]>>(new Map())
const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null)
const pool = ref<SimplePool | null>(null)
const processedMessageIds = ref(new Set<string>())
// Callback for when messages change
const onMessageAdded = ref<((peerPubkey: string) => void) | null>(null)
// Computed
const isLoggedIn = computed(() => !!currentUser.value)
// Initialize Nostr pool
const initializePool = () => {
if (!pool.value) {
@ -316,7 +431,7 @@ export function useNostrChat() {
id: event.id,
content: decryptedContent,
created_at: event.created_at,
sent: event.pubkey === currentUser.value!.pubkey,
sent: isSentByMe,
pubkey: event.pubkey
}
@ -324,12 +439,6 @@ export function useNostrChat() {
// Always use peerPubkey as the conversation key for both sent and received messages
const conversationKey = peerPubkey
console.log('Storing message with conversation key:', conversationKey)
if (!messages.value.has(conversationKey)) {
messages.value.set(conversationKey, [])
}
if (!messages.value.has(conversationKey)) {
messages.value.set(conversationKey, [])
}
@ -342,6 +451,28 @@ export function useNostrChat() {
// Force reactivity by triggering a change
messages.value = new Map(messages.value)
// Track unread messages (only for received messages, not sent ones)
if (!isSentByMe) {
const unreadData = getUnreadData(peerPubkey)
// Check if this message is newer than the last read timestamp
if (message.created_at > unreadData.lastReadTimestamp) {
const updatedUnreadData: UnreadMessageData = {
lastReadTimestamp: unreadData.lastReadTimestamp,
unreadCount: unreadData.unreadCount + 1
}
saveUnreadData(peerPubkey, updatedUnreadData)
updateUnreadCount(peerPubkey, updatedUnreadData.unreadCount)
console.log(`New unread message from ${peerPubkey}. Total unread: ${updatedUnreadData.unreadCount}`)
} else {
console.log(`Message from ${peerPubkey} is older than last read timestamp. Skipping unread count.`)
}
} else {
console.log(`Message from ${peerPubkey} was sent by current user. Skipping unread count.`)
}
// Trigger callback if set
if (onMessageAdded.value) {
onMessageAdded.value(conversationKey)
@ -503,7 +634,13 @@ export function useNostrChat() {
sendMessage,
getMessages,
clearMessages,
loadCurrentUser,
onMessageAdded
onMessageAdded,
// Notification methods
markMessagesAsRead,
getUnreadCount,
getAllUnreadCounts,
getTotalUnreadCount,
clearAllUnreadCounts
}
}