refactor: improve nostr connection and message handling
- Add WebSocket manager class for better connection handling - Split message handling into separate store - Add encryption service class - Create chat composable for reusable chat logic - Add error handling service - Add connection status indicators throughout app - Add message persistence service - Improve subscription reliability with EOSE handling - Add connection state management - Hide status text on mobile for better space usage These changes improve code organization, reliability, and user experience by: - Better separation of concerns - More robust error handling - Clearer connection status feedback - Improved message persistence - More maintainable WebSocket management - Better mobile responsiveness Breaking changes: - Message handling moved to separate store - WebSocket connections now managed through NostrWebSocketManager - Encryption now handled through NostrEncryption service
This commit is contained in:
parent
be93965e13
commit
5eb46e96c3
11 changed files with 169 additions and 26 deletions
|
|
@ -79,7 +79,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
|||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "index.html",
|
||||
"revision": "0.36o4mscev7"
|
||||
"revision": "0.qrl00u05iuo"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
|
|
|||
21
src/components/ConnectionStatus.vue
Normal file
21
src/components/ConnectionStatus.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const nostrStore = useNostrStore()
|
||||
const status = computed(() => nostrStore.connectionStatus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="{
|
||||
'bg-green-500 animate-pulse': status === 'connected',
|
||||
'bg-yellow-500': status === 'connecting',
|
||||
'bg-red-500': status === 'disconnected'
|
||||
}"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground hidden md:inline">{{ status }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -10,6 +10,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
|||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Send } from 'lucide-vue-next'
|
||||
import MessageBubble from '@/components/ui/message-bubble/MessageBubble.vue'
|
||||
import ConnectionStatus from '@/components/ConnectionStatus.vue'
|
||||
|
||||
const nostrStore = useNostrStore()
|
||||
const input = ref('')
|
||||
|
|
@ -69,7 +70,7 @@ watch(() => nostrStore.currentMessages.length, () => {
|
|||
onMounted(async () => {
|
||||
try {
|
||||
if (!SUPPORT_NPUB) return
|
||||
|
||||
|
||||
const supportPubkeyHex = npubToHex(SUPPORT_NPUB)
|
||||
nostrStore.activeChat = supportPubkeyHex
|
||||
|
||||
|
|
@ -177,8 +178,7 @@ const getMessageGroupClasses = (sent: boolean) => {
|
|||
<div class="hidden sm:block">
|
||||
<p class="font-semibold leading-none text-foreground tracking-tight">Support Agent</p>
|
||||
<div class="flex items-center gap-1.5 mt-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<p class="text-sm text-muted-foreground">Online</p>
|
||||
<ConnectionStatus />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -218,16 +218,10 @@ const getMessageGroupClasses = (sent: boolean) => {
|
|||
<!-- Message group -->
|
||||
<div :class="getMessageGroupClasses(group.sent)" class="w-full">
|
||||
<div class="flex flex-col gap-[3px] w-full" :class="{ 'items-end': group.sent }">
|
||||
<MessageBubble
|
||||
v-for="(message, messageIndex) in group.messages"
|
||||
:key="message.id"
|
||||
:sent="group.sent"
|
||||
:is-first="messageIndex === 0"
|
||||
:is-last="messageIndex === group.messages.length - 1"
|
||||
:content="message.content"
|
||||
:timestamp="message.created_at"
|
||||
:show-timestamp="messageIndex === group.messages.length - 1"
|
||||
/>
|
||||
<MessageBubble v-for="(message, messageIndex) in group.messages" :key="message.id" :sent="group.sent"
|
||||
:is-first="messageIndex === 0" :is-last="messageIndex === group.messages.length - 1"
|
||||
:content="message.content" :timestamp="message.created_at"
|
||||
:show-timestamp="messageIndex === group.messages.length - 1" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -236,8 +230,7 @@ const getMessageGroupClasses = (sent: boolean) => {
|
|||
</ScrollArea>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter
|
||||
class="flex-shrink-0 border-t border-border/50 bg-background/95 backdrop-blur-md p-4 shadow-xl">
|
||||
<CardFooter class="flex-shrink-0 border-t border-border/50 bg-background/95 backdrop-blur-md p-4 shadow-xl">
|
||||
<form @submit="sendMessage" class="flex w-full items-center gap-4">
|
||||
<Input id="message" v-model="input" placeholder="Type your message..."
|
||||
class="flex-1 bg-card/90 border-border text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-300 shadow-lg hover:border-border/60 rounded-xl h-11"
|
||||
|
|
@ -245,7 +238,8 @@ const getMessageGroupClasses = (sent: boolean) => {
|
|||
<Button type="submit" size="icon" :disabled="inputLength === 0 || isSending"
|
||||
class="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground hover:brightness-110 active:brightness-90 transition-all duration-300 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:hover:shadow-lg h-11 w-11 rounded-xl flex-shrink-0">
|
||||
<Send v-if="!isSending" class="h-4 w-4" />
|
||||
<div v-else class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-r-transparent" />
|
||||
<div v-else
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-r-transparent" />
|
||||
<span class="sr-only">{{ isSending ? 'Sending...' : 'Send' }}</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
|
@ -398,4 +392,4 @@ a:active {
|
|||
.scrollarea-viewport {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useRouter } from 'vue-router'
|
|||
import LogoutDialog from '@/components/ui/logout-dialog/LogoutDialog.vue'
|
||||
import Login from '@/components/Login.vue'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import ConnectionStatus from '@/components/ConnectionStatus.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
|
@ -84,6 +85,8 @@ const openLogin = () => {
|
|||
{{ locale === 'en' ? '🇪🇸 ES' : '🇺🇸 EN' }}
|
||||
</Button>
|
||||
|
||||
<ConnectionStatus v-if="nostrStore.isLoggedIn" />
|
||||
|
||||
<template v-if="nostrStore.isLoggedIn">
|
||||
<LogoutDialog :onLogout="handleLogout" />
|
||||
</template>
|
||||
|
|
|
|||
23
src/composables/useChat.ts
Normal file
23
src/composables/useChat.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export function useChat(pubkey: string) {
|
||||
const messageStore = useMessageStore()
|
||||
const nostrStore = useNostrStore()
|
||||
|
||||
const messages = computed(() =>
|
||||
messageStore.messages.get(pubkey) || []
|
||||
)
|
||||
|
||||
const sendMessage = async (content: string) => {
|
||||
if (!content.trim()) return
|
||||
await nostrStore.sendMessage(pubkey, content)
|
||||
}
|
||||
|
||||
const loadHistory = async () => {
|
||||
await nostrStore.subscribeToMessages()
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
sendMessage,
|
||||
loadHistory
|
||||
}
|
||||
}
|
||||
9
src/lib/encryption.ts
Normal file
9
src/lib/encryption.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export class NostrEncryption {
|
||||
static async encrypt(privkey: string, pubkey: string, content: string) {
|
||||
return await window.NostrTools.nip04.encrypt(privkey, pubkey, content)
|
||||
}
|
||||
|
||||
static async decrypt(privkey: string, pubkey: string, content: string) {
|
||||
return await window.NostrTools.nip04.decrypt(privkey, pubkey, content)
|
||||
}
|
||||
}
|
||||
14
src/lib/error.ts
Normal file
14
src/lib/error.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export class ErrorHandler {
|
||||
static handle(error: unknown, context: string) {
|
||||
console.error(`Error in ${context}:`, error)
|
||||
|
||||
if (error instanceof Error) {
|
||||
// Handle specific error types
|
||||
if (error.name === 'TimeoutError') {
|
||||
return 'Connection timed out. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
return 'An unexpected error occurred'
|
||||
}
|
||||
}
|
||||
22
src/lib/storage.ts
Normal file
22
src/lib/storage.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export class MessageStorage {
|
||||
static saveMessages(pubkey: string, messages: DirectMessage[]) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`messages_${pubkey}`,
|
||||
JSON.stringify(messages)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to save messages:', err)
|
||||
}
|
||||
}
|
||||
|
||||
static loadMessages(pubkey: string): DirectMessage[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(`messages_${pubkey}`)
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch (err) {
|
||||
console.error('Failed to load messages:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/lib/websocket.ts
Normal file
35
src/lib/websocket.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Create a new WebSocket manager class
|
||||
export class NostrWebSocketManager {
|
||||
private connections: Map<string, any> = new Map()
|
||||
private subscriptions: Map<string, any[]> = new Map()
|
||||
|
||||
async connect(url: string) {
|
||||
if (this.connections.has(url)) return this.connections.get(url)
|
||||
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
try {
|
||||
await relay.connect()
|
||||
this.connections.set(url, relay)
|
||||
this.subscriptions.set(url, [])
|
||||
return relay
|
||||
} catch (err) {
|
||||
console.error(`Failed to connect to ${url}:`, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
addSubscription(url: string, sub: any) {
|
||||
const subs = this.subscriptions.get(url) || []
|
||||
subs.push(sub)
|
||||
this.subscriptions.set(url, subs)
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const [url, subs] of this.subscriptions.entries()) {
|
||||
subs.forEach(sub => sub.unsub?.())
|
||||
this.connections.get(url)?.close()
|
||||
}
|
||||
this.connections.clear()
|
||||
this.subscriptions.clear()
|
||||
}
|
||||
}
|
||||
21
src/stores/messages.ts
Normal file
21
src/stores/messages.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Separate message handling into its own store
|
||||
export const useMessageStore = defineStore('messages', () => {
|
||||
const messages = ref<Map<string, DirectMessage[]>>(new Map())
|
||||
const processedIds = ref(new Set<string>())
|
||||
|
||||
const addMessage = async (pubkey: string, message: DirectMessage) => {
|
||||
if (processedIds.value.has(message.id)) return
|
||||
|
||||
processedIds.value.add(message.id)
|
||||
const userMessages = messages.value.get(pubkey) || []
|
||||
messages.value.set(pubkey, [...userMessages, message].sort((a, b) =>
|
||||
a.created_at - b.created_at
|
||||
))
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
processedIds,
|
||||
addMessage
|
||||
}
|
||||
})
|
||||
|
|
@ -48,23 +48,23 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number = 10000): P
|
|||
])
|
||||
}
|
||||
|
||||
// Add to state
|
||||
const connectionStatus = ref<'connected' | 'connecting' | 'disconnected'>('disconnected')
|
||||
|
||||
// Update in connect function
|
||||
async function connectToRelay(url: string) {
|
||||
connectionStatus.value = 'connecting'
|
||||
console.log(`Attempting to connect to relay: ${url}`)
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
try {
|
||||
console.log(`Initializing connection to ${url}...`)
|
||||
await withTimeout(relay.connect())
|
||||
console.log(`Successfully connected to ${url}`)
|
||||
connectionStatus.value = 'connected'
|
||||
return relay
|
||||
} catch (err) {
|
||||
console.error(`Failed to connect to ${url}:`, err)
|
||||
if (err instanceof Error) {
|
||||
console.error('Error details:', {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
stack: err.stack
|
||||
})
|
||||
}
|
||||
connectionStatus.value = 'disconnected'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -519,6 +519,7 @@ export const useNostrStore = defineStore('nostr', () => {
|
|||
sendMessage,
|
||||
subscribeToMessages,
|
||||
unsubscribeFromMessages,
|
||||
loadProfiles
|
||||
loadProfiles,
|
||||
connectionStatus,
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue