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">
|
<Badge v-else variant="secondary" class="text-xs">
|
||||||
Disconnected
|
Disconnected
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<!-- Total unread count -->
|
||||||
|
<Badge v-if="getTotalUnreadCount() > 0" variant="destructive" class="text-xs">
|
||||||
|
{{ getTotalUnreadCount() }} unread
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
|
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
|
||||||
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
|
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
|
||||||
|
|
@ -67,7 +71,7 @@
|
||||||
:key="peer.user_id"
|
:key="peer.user_id"
|
||||||
@click="selectPeer(peer)"
|
@click="selectPeer(peer)"
|
||||||
:class="[
|
: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
|
selectedPeer?.user_id === peer.user_id
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'hover:bg-muted active:bg-muted/80'
|
: 'hover:bg-muted active:bg-muted/80'
|
||||||
|
|
@ -85,6 +89,12 @@
|
||||||
{{ formatPubkey(peer.pubkey) }}
|
{{ formatPubkey(peer.pubkey) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
@ -95,32 +105,38 @@
|
||||||
<div v-else-if="isMobile && showChat" class="flex flex-col h-full">
|
<div v-else-if="isMobile && showChat" class="flex flex-col h-full">
|
||||||
<!-- Chat Header with Back Button -->
|
<!-- Chat Header with Back Button -->
|
||||||
<div class="flex items-center justify-between p-4 border-b">
|
<div class="flex items-center justify-between p-4 border-b">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@click="goBackToPeers"
|
@click="goBackToPeers"
|
||||||
class="mr-2"
|
class="mr-2"
|
||||||
>
|
>
|
||||||
<ArrowLeft class="h-5 w-5" />
|
<ArrowLeft class="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Avatar class="h-8 w-8">
|
<Avatar class="h-8 w-8">
|
||||||
<AvatarImage v-if="selectedPeer && getPeerAvatar(selectedPeer)" :src="getPeerAvatar(selectedPeer)!" />
|
<AvatarImage v-if="selectedPeer && getPeerAvatar(selectedPeer)" :src="getPeerAvatar(selectedPeer)!" />
|
||||||
<AvatarFallback>{{ selectedPeer ? getPeerInitials(selectedPeer) : 'U' }}</AvatarFallback>
|
<AvatarFallback>{{ selectedPeer ? getPeerInitials(selectedPeer) : 'U' }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-medium">{{ selectedPeer?.username || 'Unknown User' }}</h3>
|
<h3 class="font-medium">{{ selectedPeer?.username || 'Unknown User' }}</h3>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
{{ selectedPeer ? formatPubkey(selectedPeer.pubkey) : '' }}
|
{{ selectedPeer ? formatPubkey(selectedPeer.pubkey) : '' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge v-if="isConnected" variant="default" class="text-xs">
|
</div>
|
||||||
Connected
|
<div class="flex items-center space-x-2">
|
||||||
</Badge>
|
<Badge v-if="isConnected" variant="default" class="text-xs">
|
||||||
<Badge v-else variant="secondary" class="text-xs">
|
Connected
|
||||||
Disconnected
|
</Badge>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
|
|
@ -181,6 +197,10 @@
|
||||||
<Badge v-else variant="secondary" class="text-xs">
|
<Badge v-else variant="secondary" class="text-xs">
|
||||||
Disconnected
|
Disconnected
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<!-- Total unread count -->
|
||||||
|
<Badge v-if="getTotalUnreadCount() > 0" variant="destructive" class="text-xs">
|
||||||
|
{{ getTotalUnreadCount() }} unread
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
|
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
|
||||||
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
|
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
|
||||||
|
|
@ -238,7 +258,7 @@
|
||||||
:key="peer.user_id"
|
:key="peer.user_id"
|
||||||
@click="selectPeer(peer)"
|
@click="selectPeer(peer)"
|
||||||
:class="[
|
: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
|
selectedPeer?.user_id === peer.user_id
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'hover:bg-muted'
|
: 'hover:bg-muted'
|
||||||
|
|
@ -256,6 +276,12 @@
|
||||||
{{ formatPubkey(peer.pubkey) }}
|
{{ formatPubkey(peer.pubkey) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
@ -420,11 +446,16 @@ const goBackToPeers = () => {
|
||||||
const {
|
const {
|
||||||
isConnected,
|
isConnected,
|
||||||
messages,
|
messages,
|
||||||
sendMessage: sendNostrMessage,
|
isLoggedIn,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
subscribeToPeer,
|
subscribeToPeer,
|
||||||
onMessageAdded
|
sendMessage: sendNostrMessage,
|
||||||
|
onMessageAdded,
|
||||||
|
markMessagesAsRead,
|
||||||
|
getUnreadCount,
|
||||||
|
getAllUnreadCounts,
|
||||||
|
getTotalUnreadCount
|
||||||
} = useNostrChat()
|
} = useNostrChat()
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
|
|
@ -491,6 +522,9 @@ const selectPeer = async (peer: Peer) => {
|
||||||
selectedPeer.value = peer
|
selectedPeer.value = peer
|
||||||
messageInput.value = ''
|
messageInput.value = ''
|
||||||
|
|
||||||
|
// Mark messages as read for this peer
|
||||||
|
markMessagesAsRead(peer.pubkey)
|
||||||
|
|
||||||
// On mobile, show chat view
|
// On mobile, show chat view
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
showChat.value = true
|
showChat.value = true
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,32 @@ export interface NostrRelayConfig {
|
||||||
write?: boolean
|
write?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get relays from config - requires VITE_NOSTR_RELAYS to be set
|
// Add notification system for unread messages
|
||||||
const getRelays = (): NostrRelayConfig[] => {
|
interface UnreadMessageData {
|
||||||
const configuredRelays = config.nostr.relays
|
lastReadTimestamp: number
|
||||||
if (!configuredRelays || configuredRelays.length === 0) {
|
unreadCount: number
|
||||||
throw new Error('VITE_NOSTR_RELAYS environment variable must be configured for chat functionality')
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return configuredRelays.map((url: string) => ({ url, read: true, write: true }))
|
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() {
|
export function useNostrChat() {
|
||||||
|
|
@ -36,15 +54,112 @@ export function useNostrChat() {
|
||||||
const isConnected = ref(false)
|
const isConnected = ref(false)
|
||||||
const messages = ref<Map<string, ChatMessage[]>>(new Map())
|
const messages = ref<Map<string, ChatMessage[]>>(new Map())
|
||||||
const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null)
|
const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null)
|
||||||
const pool = ref<SimplePool | null>(null)
|
|
||||||
const processedMessageIds = ref(new Set<string>())
|
const processedMessageIds = ref(new Set<string>())
|
||||||
|
|
||||||
// Callback for when messages change
|
|
||||||
const onMessageAdded = ref<((peerPubkey: string) => void) | null>(null)
|
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
|
// Computed
|
||||||
const isLoggedIn = computed(() => !!currentUser.value)
|
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
|
// Initialize Nostr pool
|
||||||
const initializePool = () => {
|
const initializePool = () => {
|
||||||
if (!pool.value) {
|
if (!pool.value) {
|
||||||
|
|
@ -316,7 +431,7 @@ export function useNostrChat() {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
content: decryptedContent,
|
content: decryptedContent,
|
||||||
created_at: event.created_at,
|
created_at: event.created_at,
|
||||||
sent: event.pubkey === currentUser.value!.pubkey,
|
sent: isSentByMe,
|
||||||
pubkey: event.pubkey
|
pubkey: event.pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,12 +439,6 @@ export function useNostrChat() {
|
||||||
// Always use peerPubkey as the conversation key for both sent and received messages
|
// Always use peerPubkey as the conversation key for both sent and received messages
|
||||||
const conversationKey = peerPubkey
|
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)) {
|
if (!messages.value.has(conversationKey)) {
|
||||||
messages.value.set(conversationKey, [])
|
messages.value.set(conversationKey, [])
|
||||||
}
|
}
|
||||||
|
|
@ -342,6 +451,28 @@ export function useNostrChat() {
|
||||||
// Force reactivity by triggering a change
|
// Force reactivity by triggering a change
|
||||||
messages.value = new Map(messages.value)
|
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
|
// Trigger callback if set
|
||||||
if (onMessageAdded.value) {
|
if (onMessageAdded.value) {
|
||||||
onMessageAdded.value(conversationKey)
|
onMessageAdded.value(conversationKey)
|
||||||
|
|
@ -503,7 +634,13 @@ export function useNostrChat() {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
getMessages,
|
getMessages,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
loadCurrentUser,
|
onMessageAdded,
|
||||||
onMessageAdded
|
|
||||||
|
// Notification methods
|
||||||
|
markMessagesAsRead,
|
||||||
|
getUnreadCount,
|
||||||
|
getAllUnreadCounts,
|
||||||
|
getTotalUnreadCount,
|
||||||
|
clearAllUnreadCounts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue