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:
padreug 2025-02-15 00:26:11 +01:00
parent be93965e13
commit 5eb46e96c3
11 changed files with 169 additions and 26 deletions

View file

@ -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"), {

View 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>

View file

@ -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>

View file

@ -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>

View 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
View 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
View 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
View 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
View 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
View 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
}
})

View file

@ -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,
}
})