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:
parent
aa3509d807
commit
5fa3fcf60f
2 changed files with 219 additions and 48 deletions
|
|
@ -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>
|
||||
|
|
@ -95,32 +105,38 @@
|
|||
<div v-else-if="isMobile && showChat" class="flex flex-col h-full">
|
||||
<!-- Chat Header with Back Button -->
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="goBackToPeers"
|
||||
class="mr-2"
|
||||
>
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
</Button>
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage v-if="selectedPeer && getPeerAvatar(selectedPeer)" :src="getPeerAvatar(selectedPeer)!" />
|
||||
<AvatarFallback>{{ selectedPeer ? getPeerInitials(selectedPeer) : 'U' }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 class="font-medium">{{ selectedPeer?.username || 'Unknown User' }}</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ selectedPeer ? formatPubkey(selectedPeer.pubkey) : '' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="goBackToPeers"
|
||||
class="mr-2"
|
||||
>
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
</Button>
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage v-if="selectedPeer && getPeerAvatar(selectedPeer)" :src="getPeerAvatar(selectedPeer)!" />
|
||||
<AvatarFallback>{{ selectedPeer ? getPeerInitials(selectedPeer) : 'U' }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 class="font-medium">{{ selectedPeer?.username || 'Unknown User' }}</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ selectedPeer ? formatPubkey(selectedPeer.pubkey) : '' }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge v-if="isConnected" variant="default" class="text-xs">
|
||||
Connected
|
||||
</Badge>
|
||||
<Badge v-else variant="secondary" class="text-xs">
|
||||
Disconnected
|
||||
</Badge>
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -20,14 +20,32 @@ export interface NostrRelayConfig {
|
|||
write?: boolean
|
||||
}
|
||||
|
||||
// Get relays from config - requires VITE_NOSTR_RELAYS to be set
|
||||
const getRelays = (): NostrRelayConfig[] => {
|
||||
const configuredRelays = config.nostr.relays
|
||||
if (!configuredRelays || configuredRelays.length === 0) {
|
||||
throw new Error('VITE_NOSTR_RELAYS environment variable must be configured for chat functionality')
|
||||
// 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)
|
||||
}
|
||||
|
||||
return configuredRelays.map((url: string) => ({ url, read: true, write: true }))
|
||||
}
|
||||
|
||||
export function useNostrChat() {
|
||||
|
|
@ -36,15 +54,112 @@ export function useNostrChat() {
|
|||
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)
|
||||
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
|
||||
if (!configuredRelays || configuredRelays.length === 0) {
|
||||
throw new Error('VITE_NOSTR_RELAYS environment variable must be configured for chat functionality')
|
||||
}
|
||||
|
||||
return configuredRelays.map((url: string) => ({ url, read: true, write: true }))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue