lots of login dialog and nostr subsription relate updates

This commit is contained in:
padreug 2025-02-12 02:19:32 +01:00
parent ed1b4cb22a
commit d694f9b645
8 changed files with 323 additions and 189 deletions

View file

@ -79,7 +79,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
*/ */
workbox.precacheAndRoute([{ workbox.precacheAndRoute([{
"url": "index.html", "url": "index.html",
"revision": "0.i6hkd1cnpb" "revision": "0.dc5rn1lchj8"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View file

@ -14,10 +14,10 @@
<div class="text-center space-y-2.5"> <div class="text-center space-y-2.5">
<CardTitle class="text-2xl font-bold bg-gradient-to-r from-primary to-primary/80 inline-block text-transparent bg-clip-text"> <CardTitle class="text-2xl font-bold bg-gradient-to-r from-primary to-primary/80 inline-block text-transparent bg-clip-text">
Nostr Login {{ t('login.title') }}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Login with your Nostr private key or generate a new one {{ t('login.description') }}
</CardDescription> </CardDescription>
</div> </div>
@ -26,43 +26,53 @@
<div class="relative"> <div class="relative">
<Input <Input
v-model="privkey" v-model="privkey"
type="password" :type="showKey ? 'text' : 'password'"
placeholder="Enter your private key" :placeholder="t('login.placeholder')"
:class="[ :class="[
{ 'border-destructive': error }, { 'border-destructive': error },
{ 'pr-24': privkey }, // Add padding when we have a value to prevent overlap with button { 'pr-24': privkey },
]" ]"
@keyup.enter="login" @keyup.enter="login"
/> />
<Button <div class="absolute right-1 top-1 flex gap-1">
v-if="privkey" <Button
variant="ghost" v-if="privkey"
size="sm" variant="ghost"
class="absolute right-1 top-1 h-8" size="sm"
@click="copyKey" class="h-8"
> @click="toggleShowKey"
<Check v-if="copied" class="h-4 w-4 text-green-500" /> >
<Copy v-else class="h-4 w-4" /> <Eye v-if="!showKey" class="h-4 w-4" />
<span class="sr-only">Copy private key</span> <EyeOff v-else class="h-4 w-4" />
</Button> </Button>
<Button
v-if="privkey"
variant="ghost"
size="sm"
class="h-8"
@click="copyKey"
>
<Check v-if="copied" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
<span class="sr-only">{{ t('login.buttons.copy') }}</span>
</Button>
</div>
</div> </div>
<p v-if="error" class="text-sm text-destructive">{{ error }}</p> <p v-if="error" class="text-sm text-destructive">{{ error }}</p>
</div> </div>
<!-- Recovery message --> <!-- Recovery message -->
<div v-if="showRecoveryMessage" class="text-sm text-muted-foreground bg-muted/50 p-3 rounded-lg"> <div v-if="showRecoveryMessage" class="text-sm text-muted-foreground bg-muted/50 p-3 rounded-lg">
<p> <p>{{ t('login.recovery.message') }}</p>
Make sure to save your private key in a secure location. You can use it to recover your chat history on any device.
</p>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Button @click="login" :disabled="!privkey || isLoading"> <Button @click="login" :disabled="!privkey || isLoading">
<span v-if="isLoading" class="h-4 w-4 animate-spin rounded-full border-2 border-background border-r-transparent" /> <Loader2 v-if="isLoading" class="h-4 w-4 animate-spin" />
<span v-else>Login</span> <span v-else>{{ t('login.buttons.login') }}</span>
</Button> </Button>
<Button variant="outline" @click="generateKey"> <Button variant="outline" @click="generateKey" :disabled="isLoading">
Generate New Key {{ t('login.buttons.generate') }}
</Button> </Button>
</div> </div>
</div> </div>
@ -71,12 +81,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useNostrStore } from '@/stores/nostr' import { useNostrStore } from '@/stores/nostr'
import { KeyRound, Copy, Check } from 'lucide-vue-next' import { KeyRound, Copy, Check, Eye, EyeOff, Loader2 } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { isValidPrivateKey, formatPrivateKey } from '@/lib/nostr'
const { t } = useI18n()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'success'): void (e: 'success'): void
}>() }>()
@ -87,25 +100,50 @@ const error = ref('')
const isLoading = ref(false) const isLoading = ref(false)
const copied = ref(false) const copied = ref(false)
const showRecoveryMessage = ref(false) const showRecoveryMessage = ref(false)
const showKey = ref(false)
const toggleShowKey = () => {
showKey.value = !showKey.value
}
const login = async () => { const login = async () => {
if (!privkey.value) return if (!privkey.value || isLoading.value) return
const formattedKey = formatPrivateKey(privkey.value)
if (!isValidPrivateKey(formattedKey)) {
error.value = t('login.error')
return
}
try { try {
isLoading.value = true isLoading.value = true
await nostrStore.login(privkey.value) error.value = ''
await nostrStore.login(formattedKey)
emit('success') emit('success')
} catch (err) { } catch (err) {
console.error('Login failed:', err) console.error('Login failed:', err)
error.value = 'Invalid private key' error.value = err instanceof Error ? err.message : t('login.error')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
const generateKey = () => { const generateKey = () => {
privkey.value = window.NostrTools.generatePrivateKey() if (isLoading.value) return
showRecoveryMessage.value = true
try {
const newKey = window.NostrTools.generatePrivateKey()
if (!isValidPrivateKey(newKey)) {
throw new Error('Generated key is invalid')
}
privkey.value = newKey
error.value = ''
showRecoveryMessage.value = true
showKey.value = true // Show the key when generated
} catch (err) {
console.error('Failed to generate key:', err)
error.value = t('login.error')
}
} }
const copyKey = async () => { const copyKey = async () => {

View file

@ -18,6 +18,20 @@ export default {
confirm: 'Logout' confirm: 'Logout'
} }
}, },
login: {
title: 'Nostr Login',
description: 'Login with your Nostr private key or generate a new one',
placeholder: 'Enter your private key',
error: 'Invalid private key',
buttons: {
login: 'Login',
generate: 'Generate New Key',
copy: 'Copy private key'
},
recovery: {
message: 'Make sure to save your private key in a secure location. You can use it to recover your chat history on any device.'
}
},
home: { home: {
title: 'Find Bitcoin Lightning Acceptors', title: 'Find Bitcoin Lightning Acceptors',
subtitle: 'Discover local businesses, services, and venues that accept Bitcoin Lightning payments.', subtitle: 'Discover local businesses, services, and venues that accept Bitcoin Lightning payments.',

View file

@ -81,5 +81,19 @@ export default {
cancel: 'Cancelar', cancel: 'Cancelar',
confirm: 'Cerrar Sesión' confirm: 'Cerrar Sesión'
} }
},
login: {
title: 'Iniciar Sesión Nostr',
description: 'Inicia sesión con tu llave privada Nostr o genera una nueva',
placeholder: 'Ingresa tu llave privada',
error: 'Llave privada inválida',
buttons: {
login: 'Iniciar Sesión',
generate: 'Generar Nueva Llave',
copy: 'Copiar llave privada'
},
recovery: {
message: 'Asegúrate de guardar tu llave privada en un lugar seguro. Puedes usarla para recuperar tu historial de chat en cualquier dispositivo.'
}
} }
} }

View file

@ -105,4 +105,19 @@ export function npubToHex(npub: string): string {
export function hexToNpub(hex: string): string { export function hexToNpub(hex: string): string {
return window.NostrTools.nip19.npubEncode(hex) return window.NostrTools.nip19.npubEncode(hex)
}
export function isValidPrivateKey(key: string): boolean {
try {
if (!/^[0-9a-fA-F]{64}$/.test(key)) {
return false
}
return true
} catch {
return false
}
}
export function formatPrivateKey(key: string): string {
return key.trim().toLowerCase()
} }

View file

@ -1,21 +0,0 @@
"directory": {
"categories": {
"all": "All",
"tuktuk": "Tuk-tuk",
"restaurant": "Restaurant",
"lodging": "Lodging",
"goods": "Goods",
"services": "Services",
"taxi": "Taxi",
"lancha": "Boat",
"other": "Other"
}
}
"home": {
"title": "Find Bitcoin Lightning Acceptors",
"subtitle": "Discover local businesses, services, and venues that accept Bitcoin Lightning payments.",
"selectTown": "Select your location",
"browse": "Browse Directory",
"learnMore": "Learn More"
}

View file

@ -1,21 +0,0 @@
"directory": {
"categories": {
"all": "Todo",
"restaurant": "Restaurante",
"tuktuk": "Tuk-tuk",
"lodging": "Hospedaje",
"goods": "Productos",
"services": "Servicios",
"taxi": "Taxi",
"lancha": "Lancha",
"other": "Otro"
}
}
"home": {
"title": "Encuentra Aceptadores de Bitcoin Lightning",
"subtitle": "Descubre negocios locales, servicios y lugares que aceptan pagos Bitcoin Lightning.",
"selectTown": "Selecciona tu ubicación",
"browse": "Explorar Directorio",
"learnMore": "Más Información"
}

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { NostrEvent, NostrProfile, NostrAccount, DirectMessage } from '../types/nostr' import type { NostrEvent, NostrProfile, NostrAccount, DirectMessage } from '../types/nostr'
import { isValidPrivateKey, formatPrivateKey } from '@/lib/nostr'
declare global { declare global {
interface Window { interface Window {
@ -41,12 +42,21 @@ const DEFAULT_RELAYS = [
const SUPPORT_NPUB = import.meta.env.VITE_SUPPORT_NPUB const SUPPORT_NPUB = import.meta.env.VITE_SUPPORT_NPUB
// Helper functions // Helper functions
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number = 10000): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Operation timed out')), timeoutMs)
)
])
}
async function connectToRelay(url: string) { async function connectToRelay(url: string) {
console.log(`Attempting to connect to relay: ${url}`) console.log(`Attempting to connect to relay: ${url}`)
const relay = window.NostrTools.relayInit(url) const relay = window.NostrTools.relayInit(url)
try { try {
console.log(`Initializing connection to ${url}...`) console.log(`Initializing connection to ${url}...`)
await relay.connect() await withTimeout(relay.connect())
console.log(`Successfully connected to ${url}`) console.log(`Successfully connected to ${url}`)
return relay return relay
} catch (err) { } catch (err) {
@ -114,46 +124,88 @@ export const useNostrStore = defineStore('nostr', () => {
// Initialize connection if account exists // Initialize connection if account exists
async function init() { async function init() {
if (account.value) { if (!account.value) return
try {
// Clear existing state // Clear existing state
messages.value.clear() messages.value.clear()
profiles.value.clear() profiles.value.clear()
processedMessageIds.value.clear() processedMessageIds.value.clear()
// Close existing connections // Close existing connections
relayPool.value.forEach(relay => relay.close()) relayPool.value.forEach(relay => {
try {
relay.close()
} catch (err) {
console.error('Error closing relay:', err)
}
})
relayPool.value = [] relayPool.value = []
// Connect to relays // Connect to relays with timeout
const connectedRelays = await Promise.all( const connectionPromises = account.value.relays.map(async relay => {
account.value.relays.map(relay => connectToRelay(relay.url)) try {
) return await withTimeout(connectToRelay(relay.url), 5000)
} catch (err) {
relayPool.value = connectedRelays.filter(relay => relay !== null) console.error(`Timeout connecting to ${relay.url}:`, err)
return null
}
})
// Subscribe to messages and load history const connectedRelays = await Promise.all(connectionPromises)
await Promise.all([ relayPool.value = connectedRelays.filter((relay): relay is NonNullable<typeof relay> => relay !== null)
subscribeToMessages(),
loadProfiles()
])
// Set active chat to support agent if (relayPool.value.length === 0) {
throw new Error('Failed to connect to any relays')
}
// Set active chat to support agent first
activeChat.value = SUPPORT_NPUB activeChat.value = SUPPORT_NPUB
// Subscribe to messages with shorter timeout
try {
await withTimeout(subscribeToMessages(), 10000)
} catch (err) {
console.error('Failed to subscribe to messages:', err)
// Continue even if subscription fails
}
// Load profiles with shorter timeout
try {
await withTimeout(loadProfiles(), 5000)
} catch (err) {
console.error('Failed to load profiles:', err)
// Continue even if profile loading fails
}
} catch (err) {
console.error('Failed to initialize:', err)
throw new Error('Failed to connect to the network. Please try again.')
} }
} }
// Actions // Actions
async function login(privkey: string) { async function login(privkey: string) {
const pubkey = window.NostrTools.getPublicKey(privkey) if (!isValidPrivateKey(privkey)) {
throw new Error('Invalid private key')
}
const formattedKey = formatPrivateKey(privkey)
const pubkey = window.NostrTools.getPublicKey(formattedKey)
account.value = { account.value = {
pubkey, pubkey,
privkey, privkey: formattedKey,
relays: DEFAULT_RELAYS.map(url => ({ url, read: true, write: true })) relays: DEFAULT_RELAYS.map(url => ({ url, read: true, write: true }))
} }
// Initialize connection and load messages try {
await init() // Initialize connection with a global timeout
await withTimeout(init(), 30000) // 30 second total timeout for the entire login process
} catch (err) {
// If initialization fails, clear the account
account.value = null
throw err
}
} }
async function loadProfiles() { async function loadProfiles() {
@ -254,125 +306,168 @@ export const useNostrStore = defineStore('nostr', () => {
unsubscribeFromMessages() unsubscribeFromMessages()
} }
// Filter for received messages with history let hasReceivedMessage = false
const receivedFilter = { const subscriptionTimeout = setTimeout(() => {
kinds: [4], if (!hasReceivedMessage) {
'#p': [account.value.pubkey], console.log('No messages received, considering subscription successful')
since: 0 // Get all historical messages hasReceivedMessage = true
} }
}, 5000)
// Filter for sent messages with history try {
const sentFilter = { const subscribeToRelay = (relay: any) => {
kinds: [4], return new Promise((resolve) => {
authors: [account.value.pubkey], let subs: any[] = []
since: 0 // Get all historical messages let resolved = false
}
const subscribeToRelay = (relay: any) => { // Set a timeout for the entire subscription
return new Promise((resolve) => { const timeout = setTimeout(() => {
let eoseCount = 0 if (!resolved) {
console.log('Subscription timeout, but continuing...')
resolved = true
resolve(true)
}
}, 8000)
// Subscribe to received messages
const receivedSub = relay.sub([receivedFilter])
receivedSub.on('event', async (event: NostrEvent) => {
try { try {
// Skip if we've already processed this message // Filter for received messages with history
if (processedMessageIds.value.has(event.id)) { const receivedFilter = {
return kinds: [4],
'#p': [account.value!.pubkey],
since: 0
} }
const decrypted = await window.NostrTools.nip04.decrypt( // Filter for sent messages with history
account.value!.privkey, const sentFilter = {
event.pubkey, kinds: [4],
event.content authors: [account.value!.pubkey],
) since: 0
const dm: DirectMessage = {
id: event.id,
pubkey: event.pubkey,
content: decrypted,
created_at: event.created_at,
sent: false
} }
await addMessage(event.pubkey, dm) // Subscribe to received messages
const receivedSub = relay.sub([receivedFilter])
subs.push(receivedSub)
receivedSub.on('event', async (event: NostrEvent) => {
hasReceivedMessage = true
try {
if (processedMessageIds.value.has(event.id)) return
const decrypted = await window.NostrTools.nip04.decrypt(
account.value!.privkey,
event.pubkey,
event.content
)
// Load profile if not already loaded const dm: DirectMessage = {
if (!profiles.value.has(event.pubkey)) { id: event.id,
await loadProfiles() pubkey: event.pubkey,
content: decrypted,
created_at: event.created_at,
sent: false
}
await addMessage(event.pubkey, dm)
processedMessageIds.value.add(event.id)
if (!profiles.value.has(event.pubkey)) {
await loadProfiles()
}
} catch (err) {
console.error('Failed to decrypt received message:', err)
}
})
// Subscribe to sent messages
const sentSub = relay.sub([sentFilter])
subs.push(sentSub)
sentSub.on('event', async (event: NostrEvent) => {
hasReceivedMessage = true
try {
if (processedMessageIds.value.has(event.id)) return
const targetPubkey = event.tags.find(tag => tag[0] === 'p')?.[1]
if (!targetPubkey) return
const decrypted = await window.NostrTools.nip04.decrypt(
account.value!.privkey,
targetPubkey,
event.content
)
const dm: DirectMessage = {
id: event.id,
pubkey: targetPubkey,
content: decrypted,
created_at: event.created_at,
sent: true
}
await addMessage(targetPubkey, dm)
processedMessageIds.value.add(event.id)
} catch (err) {
console.error('Failed to decrypt sent message:', err)
}
})
// Store subscriptions for cleanup
currentSubscription.value = {
unsub: () => {
clearTimeout(timeout)
subs.forEach(sub => {
try {
if (sub && typeof sub.unsub === 'function') {
sub.unsub()
}
} catch (err) {
console.error('Failed to unsubscribe:', err)
}
})
}
} }
// Consider subscription successful immediately
if (!resolved) {
resolved = true
resolve(true)
}
} catch (err) { } catch (err) {
console.error('Failed to decrypt received message:', err) console.error('Error in subscription:', err)
} if (!resolved) {
}) resolved = true
resolve(true)
// Subscribe to sent messages
const sentSub = relay.sub([sentFilter])
sentSub.on('event', async (event: NostrEvent) => {
try {
// Skip if we've already processed this message
if (processedMessageIds.value.has(event.id)) {
return
} }
// Find the target pubkey from the p tag
const targetPubkey = event.tags.find(tag => tag[0] === 'p')?.[1]
if (!targetPubkey) return
const decrypted = await window.NostrTools.nip04.decrypt(
account.value!.privkey,
targetPubkey,
event.content
)
const dm: DirectMessage = {
id: event.id,
pubkey: targetPubkey,
content: decrypted,
created_at: event.created_at,
sent: true
}
await addMessage(targetPubkey, dm)
} catch (err) {
console.error('Failed to decrypt sent message:', err)
} }
}) })
}
// Listen for end of stored events // Wait for relays
receivedSub.on('eose', () => { await Promise.all(
eoseCount++ relayPool.value.map(relay =>
if (eoseCount >= 2) { // Both subscriptions have finished withTimeout(subscribeToRelay(relay), 10000)
resolve(true) .catch(() => true)
} )
}) )
sentSub.on('eose', () => { clearTimeout(subscriptionTimeout)
eoseCount++ return true
if (eoseCount >= 2) { // Both subscriptions have finished
resolve(true)
}
})
// Store subscriptions for cleanup } catch (err) {
currentSubscription.value = { clearTimeout(subscriptionTimeout)
unsub: () => { console.error('Failed to subscribe to messages:', err)
receivedSub.unsub() return true
sentSub.unsub()
}
}
})
} }
// Wait for all relays to load their historical messages
await Promise.all(relayPool.value.map(relay => subscribeToRelay(relay)))
} }
function unsubscribeFromMessages() { function unsubscribeFromMessages() {
if (currentSubscription.value) { if (currentSubscription.value && typeof currentSubscription.value.unsub === 'function') {
currentSubscription.value.unsub() try {
currentSubscription.value.unsub()
} catch (err) {
console.error('Failed to unsubscribe:', err)
}
currentSubscription.value = null currentSubscription.value = null
} }
} }