new message indicator
This commit is contained in:
parent
2bbb9ae938
commit
074a1fc534
3 changed files with 160 additions and 22 deletions
|
|
@ -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>
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -34,4 +34,5 @@ export interface DirectMessage {
|
||||||
content: string
|
content: string
|
||||||
created_at: number
|
created_at: number
|
||||||
sent: boolean
|
sent: boolean
|
||||||
|
fromStorage?: boolean
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue