new message indicator

This commit is contained in:
padreug 2025-02-15 02:35:05 +01:00
parent 2bbb9ae938
commit 074a1fc534
3 changed files with 160 additions and 22 deletions

View file

@ -1,21 +1,122 @@
<script setup lang="ts"> <script setup lang="ts">
import { useNostrStore } from '@/stores/nostr' import { useNostrStore } from '@/stores/nostr'
import { useTheme } from '@/components/theme-provider'
import { useRouter, useRoute } from 'vue-router'
import { computed } from 'vue' import { computed } from 'vue'
const nostrStore = useNostrStore() const nostrStore = useNostrStore()
const { theme } = useTheme()
const router = useRouter()
const route = useRoute()
const status = computed(() => nostrStore.connectionStatus) const status = computed(() => nostrStore.connectionStatus)
const hasUnread = computed(() => nostrStore.hasUnreadMessages)
const isOnSupportChat = computed(() => route.path === '/support')
// Compute theme-specific classes
const indicatorClasses = computed(() => ({
'bg-primary/20 dark:bg-primary/30': true,
'shadow-[0_0_8px_rgba(var(--primary),0.3)] dark:shadow-[0_0_12px_rgba(var(--primary),0.4)]': true
}))
const breatheClasses = computed(() => ({
'bg-primary/10 dark:bg-primary/20': true,
'shadow-[0_0_12px_rgba(var(--primary),0.2)] dark:shadow-[0_0_16px_rgba(var(--primary),0.3)]': true
}))
async function handleIndicatorClick() {
if (isOnSupportChat.value) {
return // Do nothing if already on support chat
}
if (status.value === 'connected') {
// Navigate to support chat if connected
router.push('/support')
} else {
// Attempt to reconnect if not connected
try {
await nostrStore.reconnectToRelays()
} catch (err) {
console.error('Failed to reconnect:', err)
}
}
}
</script> </script>
<template> <template>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="relative">
<div <div
class="h-2 w-2 rounded-full" class="relative h-4 w-4 flex items-center justify-center cursor-pointer hover:scale-110 transition-transform duration-200"
@click="handleIndicatorClick"
:title="isOnSupportChat
? status
: status === 'connected'
? 'Click to open support chat'
: 'Click to reconnect'"
>
<!-- Base connection dot -->
<div
class="h-2 w-2 rounded-full z-20 relative transition-colors duration-300"
:class="{ :class="{
'bg-green-500 animate-pulse': status === 'connected', 'bg-green-500 shadow-green-500/30': status === 'connected',
'bg-yellow-500': status === 'connecting', 'bg-yellow-500 shadow-yellow-500/30': status === 'connecting',
'bg-red-500': status === 'disconnected' 'bg-red-500 shadow-red-500/30': status === 'disconnected'
}" }"
/> />
<span class="text-sm text-muted-foreground hidden md:inline">{{ status }}</span> <!-- Message indicator -->
<div
v-if="hasUnread"
class="absolute inset-0 message-indicator"
>
<div
class="absolute inset-0 rounded-full transition-colors duration-300"
:class="indicatorClasses"
></div>
<div
class="absolute inset-0 rounded-full transition-colors duration-300 animate-breathe"
:class="breatheClasses"
></div>
</div>
</div>
</div>
<span class="text-sm text-muted-foreground hidden md:inline">
{{ status }}
<span v-if="hasUnread" class="text-primary font-medium ml-1">
(1)
</span>
</span>
</div> </div>
</template> </template>
<style scoped>
.message-indicator {
transform-origin: center;
animation: gentle-pulse 2s ease-in-out infinite;
}
@keyframes gentle-pulse {
0% { transform: scale(0.95); }
50% { transform: scale(1.1); }
100% { transform: scale(0.95); }
}
.animate-breathe {
animation: breathe 2s ease-in-out infinite;
}
@keyframes breathe {
0% {
transform: scale(1);
opacity: 0.3;
}
50% {
transform: scale(1.5);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0.3;
}
}
</style>

View file

@ -50,6 +50,10 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number = 10000): P
// Add to state // Add to state
const connectionStatus = ref<'connected' | 'connecting' | 'disconnected'>('disconnected') const connectionStatus = ref<'connected' | 'connecting' | 'disconnected'>('disconnected')
const hasUnreadMessages = ref(false)
const viewedMessageIds = ref<Set<string>>(new Set(
JSON.parse(localStorage.getItem('nostr_viewed_messages') || '[]')
))
// Update in connect function // Update in connect function
async function connectToRelay(url: string) { async function connectToRelay(url: string) {
@ -224,6 +228,10 @@ export const useNostrStore = defineStore('nostr', () => {
activeChat.value = null activeChat.value = null
localStorage.removeItem('nostr_messages') localStorage.removeItem('nostr_messages')
localStorage.removeItem('nostr_account') localStorage.removeItem('nostr_account')
hasUnreadMessages.value = false
localStorage.removeItem('nostr_unread_messages')
viewedMessageIds.value.clear()
localStorage.removeItem('nostr_viewed_messages')
} }
const addMessage = async (pubkey: string, message: DirectMessage) => { const addMessage = async (pubkey: string, message: DirectMessage) => {
@ -232,25 +240,28 @@ export const useNostrStore = defineStore('nostr', () => {
return return
} }
// Add message ID to processed set
processedMessageIds.value.add(message.id) processedMessageIds.value.add(message.id)
// Add message to the map
const userMessages = messages.value.get(pubkey) || [] const userMessages = messages.value.get(pubkey) || []
// Check for duplicates by content and timestamp (backup check) // Check for duplicates
const isDuplicate = userMessages.some(msg => const isDuplicate = userMessages.some(msg =>
msg.content === message.content && msg.content === message.content &&
Math.abs(msg.created_at - message.created_at) < 1 Math.abs(msg.created_at - message.created_at) < 1
) )
if (!isDuplicate) { if (!isDuplicate) {
messages.value.set(pubkey, [...userMessages, message]) messages.value.set(pubkey, [...userMessages, message].sort((a, b) =>
a.created_at - b.created_at
))
// Sort messages by timestamp // Only set unread if:
const sortedMessages = messages.value.get(pubkey) || [] // 1. Message came from websocket (not storage)
sortedMessages.sort((a, b) => a.created_at - b.created_at) // 2. Not from current chat
messages.value.set(pubkey, sortedMessages) // 3. Not sent by us
if (!message.fromStorage && pubkey !== activeChat.value && !message.sent) {
console.log('New unread message received:', { pubkey, messageId: message.id })
hasUnreadMessages.value = true
}
} }
} }
@ -340,11 +351,11 @@ export const useNostrStore = defineStore('nostr', () => {
pubkey: event.pubkey, pubkey: event.pubkey,
content: decrypted, content: decrypted,
created_at: event.created_at, created_at: event.created_at,
sent: false sent: false,
fromStorage: false // Mark as not from storage
} }
await addMessage(event.pubkey, dm) await addMessage(event.pubkey, dm)
processedMessageIds.value.add(event.id)
} catch (err) { } catch (err) {
console.error('Failed to decrypt received message:', err) console.error('Failed to decrypt received message:', err)
} }
@ -374,7 +385,6 @@ export const useNostrStore = defineStore('nostr', () => {
} }
await addMessage(targetPubkey, dm) await addMessage(targetPubkey, dm)
processedMessageIds.value.add(event.id)
} catch (err) { } catch (err) {
console.error('Failed to decrypt sent message:', err) console.error('Failed to decrypt sent message:', err)
} }
@ -564,6 +574,30 @@ export const useNostrStore = defineStore('nostr', () => {
window.addEventListener('focus', handleVisibilityChange) window.addEventListener('focus', handleVisibilityChange)
} }
// Add function to clear unread state
function clearUnreadMessages() {
hasUnreadMessages.value = false
localStorage.setItem('nostr_unread_messages', 'false')
// Mark all current chat messages as viewed
if (activeChat.value) {
const chatMessages = messages.value.get(activeChat.value) || []
chatMessages.forEach(msg => {
viewedMessageIds.value.add(msg.id)
})
// Persist viewed message IDs
localStorage.setItem('nostr_viewed_messages',
JSON.stringify(Array.from(viewedMessageIds.value))
)
}
}
// Add to watch section
watch(activeChat, () => {
// Clear unread messages when changing to a chat
clearUnreadMessages()
})
return { return {
account, account,
profiles, profiles,
@ -580,5 +614,7 @@ export const useNostrStore = defineStore('nostr', () => {
loadProfiles, loadProfiles,
connectionStatus, connectionStatus,
hasActiveSubscription, hasActiveSubscription,
hasUnreadMessages,
clearUnreadMessages,
} }
}) })

View file

@ -34,4 +34,5 @@ export interface DirectMessage {
content: string content: string
created_at: number created_at: number
sent: boolean sent: boolean
fromStorage?: boolean
} }