Squash merge feature/publish-metadata into main
This commit is contained in:
parent
cc5e0dbef6
commit
875bf50765
7 changed files with 629 additions and 21 deletions
|
|
@ -5,9 +5,10 @@ import { Button } from '@/components/ui/button'
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { LogoutConfirmDialog } from '@/components/ui/LogoutConfirmDialog'
|
||||
import { User, LogOut, Settings, Key, Wallet, ExternalLink } from 'lucide-vue-next'
|
||||
import { User, LogOut, Settings, Key, Wallet, ExternalLink, ArrowLeft } from 'lucide-vue-next'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { ProfileSettings } from '@/modules/base'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
|
|
@ -22,6 +23,7 @@ const emit = defineEmits<Emits>()
|
|||
|
||||
const userDisplay = computed(() => auth.userDisplay.value)
|
||||
const showLogoutConfirm = ref(false)
|
||||
const showSettings = ref(false)
|
||||
|
||||
// Get the API base URL from environment variables
|
||||
const apiBaseUrl = import.meta.env.VITE_LNBITS_BASE_URL || ''
|
||||
|
|
@ -44,24 +46,46 @@ function handleOpenWallet() {
|
|||
}
|
||||
|
||||
function handleClose() {
|
||||
showSettings.value = false
|
||||
emit('update:isOpen', false)
|
||||
}
|
||||
|
||||
function toggleSettings() {
|
||||
showSettings.value = !showSettings.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="isOpen" @update:open="handleClose">
|
||||
<DialogContent class="sm:max-w-[500px]">
|
||||
<DialogContent class="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="showSettings"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 -ml-2"
|
||||
@click="toggleSettings"
|
||||
>
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
</Button>
|
||||
<User class="w-5 h-5" />
|
||||
User Profile
|
||||
<span v-if="showSettings">Edit Profile</span>
|
||||
<span v-else>User Profile</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your account information and settings
|
||||
<span v-if="showSettings">Update your profile information and Nostr identity</span>
|
||||
<span v-else>Your account information and settings</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="userDisplay" class="space-y-6">
|
||||
<!-- Profile Settings View -->
|
||||
<div v-if="showSettings">
|
||||
<ProfileSettings />
|
||||
</div>
|
||||
|
||||
<!-- Profile Info View -->
|
||||
<div v-else-if="userDisplay" class="space-y-6">
|
||||
<!-- User Info Card -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -116,10 +140,10 @@ function handleClose() {
|
|||
Open Wallet
|
||||
<ExternalLink class="w-3 h-3 ml-auto" />
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" class="w-full justify-start gap-2">
|
||||
|
||||
<Button variant="outline" @click="toggleSettings" class="w-full justify-start gap-2">
|
||||
<Settings class="w-4 h-4" />
|
||||
Account Settings
|
||||
Edit Profile
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" class="w-full justify-start gap-2">
|
||||
|
|
|
|||
|
|
@ -137,6 +137,9 @@ export const SERVICE_TOKENS = {
|
|||
PROFILE_SERVICE: Symbol('profileService'),
|
||||
REACTION_SERVICE: Symbol('reactionService'),
|
||||
|
||||
// Nostr metadata services
|
||||
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
||||
|
||||
// Events services
|
||||
EVENTS_SERVICE: Symbol('eventsService'),
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { BaseService } from '@/core/base/BaseService'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits'
|
||||
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
|
||||
|
||||
export class AuthService extends BaseService {
|
||||
// Service metadata
|
||||
|
|
@ -79,16 +81,19 @@ export class AuthService extends BaseService {
|
|||
|
||||
async login(credentials: LoginCredentials): Promise<void> {
|
||||
this.isLoading.value = true
|
||||
|
||||
|
||||
try {
|
||||
await this.lnbitsAPI.login(credentials)
|
||||
const userData = await this.lnbitsAPI.getCurrentUser()
|
||||
|
||||
|
||||
this.user.value = userData
|
||||
this.isAuthenticated.value = true
|
||||
|
||||
|
||||
eventBus.emit('auth:login', { user: userData }, 'auth-service')
|
||||
|
||||
|
||||
// Auto-broadcast Nostr metadata on login
|
||||
this.broadcastNostrMetadata()
|
||||
|
||||
} catch (error) {
|
||||
const err = this.handleError(error, 'login')
|
||||
eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
|
||||
|
|
@ -100,16 +105,19 @@ export class AuthService extends BaseService {
|
|||
|
||||
async register(data: RegisterData): Promise<void> {
|
||||
this.isLoading.value = true
|
||||
|
||||
|
||||
try {
|
||||
await this.lnbitsAPI.register(data)
|
||||
const userData = await this.lnbitsAPI.getCurrentUser()
|
||||
|
||||
|
||||
this.user.value = userData
|
||||
this.isAuthenticated.value = true
|
||||
|
||||
|
||||
eventBus.emit('auth:login', { user: userData }, 'auth-service')
|
||||
|
||||
|
||||
// Auto-broadcast Nostr metadata on registration
|
||||
this.broadcastNostrMetadata()
|
||||
|
||||
} catch (error) {
|
||||
const err = this.handleError(error, 'register')
|
||||
eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
|
||||
|
|
@ -156,7 +164,19 @@ export class AuthService extends BaseService {
|
|||
try {
|
||||
this.isLoading.value = true
|
||||
const updatedUser = await this.lnbitsAPI.updateProfile(data)
|
||||
this.user.value = updatedUser
|
||||
|
||||
// Preserve prvkey and pubkey from existing user since /auth/update doesn't return them
|
||||
this.user.value = {
|
||||
...updatedUser,
|
||||
pubkey: this.user.value?.pubkey || updatedUser.pubkey,
|
||||
prvkey: this.user.value?.prvkey || updatedUser.prvkey
|
||||
}
|
||||
|
||||
// Auto-broadcast Nostr metadata when profile is updated
|
||||
// Note: ProfileSettings component will also manually broadcast,
|
||||
// but this ensures metadata stays in sync even if updated elsewhere
|
||||
this.broadcastNostrMetadata()
|
||||
|
||||
} catch (error) {
|
||||
const err = this.handleError(error, 'updateProfile')
|
||||
throw err
|
||||
|
|
@ -165,6 +185,26 @@ export class AuthService extends BaseService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast user metadata to Nostr relays (NIP-01 kind 0)
|
||||
* Called automatically on login, registration, and profile updates
|
||||
*/
|
||||
private async broadcastNostrMetadata(): Promise<void> {
|
||||
try {
|
||||
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
|
||||
if (metadataService && this.user.value?.pubkey) {
|
||||
// Broadcast in background - don't block login/update
|
||||
metadataService.publishMetadata().catch(error => {
|
||||
console.warn('Failed to broadcast Nostr metadata:', error)
|
||||
// Don't throw - this is a non-critical background operation
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// If service isn't available yet, silently skip
|
||||
console.debug('Nostr metadata service not yet available')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when service is disposed
|
||||
*/
|
||||
|
|
|
|||
325
src/modules/base/components/ProfileSettings.vue
Normal file
325
src/modules/base/components/ProfileSettings.vue
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Profile Settings</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Manage your profile information and Nostr identity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<form @submit="onSubmit" class="space-y-6">
|
||||
<!-- Profile Picture -->
|
||||
<FormField name="picture">
|
||||
<FormItem>
|
||||
<FormLabel>Profile Picture</FormLabel>
|
||||
<FormDescription>
|
||||
Upload a profile picture. This will be published to your Nostr profile.
|
||||
</FormDescription>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Current picture preview -->
|
||||
<div v-if="currentPictureUrl" class="relative">
|
||||
<img
|
||||
:src="currentPictureUrl"
|
||||
alt="Profile picture"
|
||||
class="h-20 w-20 rounded-full object-cover border-2 border-border"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border">
|
||||
<User class="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<!-- Upload component -->
|
||||
<ImageUpload
|
||||
v-model="uploadedPicture"
|
||||
:multiple="false"
|
||||
:max-files="1"
|
||||
:max-size-mb="5"
|
||||
:disabled="isUpdating"
|
||||
:allow-camera="true"
|
||||
placeholder="Upload picture"
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Username (Read-only for now) -->
|
||||
<!-- TODO: Enable username editing in the future
|
||||
Note: Changing username would require updating:
|
||||
- LNURLp extension: Lightning address (lnurlp)
|
||||
- Nostr extension: NIP-05 identifier
|
||||
This needs to be coordinated across multiple LNbits extensions
|
||||
-->
|
||||
<FormField v-slot="{ componentField }" name="username">
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter username"
|
||||
:disabled="true"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your unique username. This is used for your NIP-05 identifier ({{ nip05Preview }}) and Lightning Address. Username changes are not currently supported.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Display Name (optional) -->
|
||||
<FormField v-slot="{ componentField }" name="display_name">
|
||||
<FormItem>
|
||||
<FormLabel>Display Name (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter display name"
|
||||
:disabled="isUpdating"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly display name shown on your profile
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Lightning Address / NIP-05 (read-only info) -->
|
||||
<div class="rounded-lg border p-4 space-y-2 bg-muted/50">
|
||||
<h4 class="text-sm font-medium">Nostr Identity</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<Zap class="h-4 w-4 text-yellow-500" />
|
||||
<span class="text-muted-foreground">Lightning Address:</span>
|
||||
<code class="text-xs bg-background px-2 py-1 rounded">{{ lightningAddress }}</code>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Hash class="h-4 w-4 text-purple-500" />
|
||||
<span class="text-muted-foreground">NIP-05:</span>
|
||||
<code class="text-xs bg-background px-2 py-1 rounded">{{ nip05Preview }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
These identifiers are automatically derived from your username
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div v-if="updateError" class="text-sm text-destructive">
|
||||
{{ updateError }}
|
||||
</div>
|
||||
|
||||
<!-- Success Display -->
|
||||
<div v-if="updateSuccess" class="text-sm text-green-600">
|
||||
Profile updated successfully!
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="isUpdating || !isFormValid"
|
||||
class="flex-1"
|
||||
>
|
||||
<span v-if="isUpdating">Updating...</span>
|
||||
<span v-else>Update Profile</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="isBroadcasting"
|
||||
@click="broadcastMetadata"
|
||||
class="flex-1"
|
||||
>
|
||||
<Radio class="mr-2 h-4 w-4" :class="{ 'animate-pulse': isBroadcasting }" />
|
||||
<span v-if="isBroadcasting">Broadcasting...</span>
|
||||
<span v-else>Broadcast to Nostr</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Your profile is automatically broadcast to Nostr when you update it or log in.
|
||||
Use the "Broadcast to Nostr" button to manually re-broadcast your profile.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { User, Zap, Hash, Radio } from 'lucide-vue-next'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import ImageUpload from './ImageUpload.vue'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ImageUploadService } from '../services/ImageUploadService'
|
||||
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
|
||||
// Services
|
||||
const { user, updateProfile } = useAuth()
|
||||
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
|
||||
const toast = useToast()
|
||||
|
||||
// Local state
|
||||
const isUpdating = ref(false)
|
||||
const isBroadcasting = ref(false)
|
||||
const updateError = ref<string | null>(null)
|
||||
const updateSuccess = ref(false)
|
||||
const uploadedPicture = ref<any[]>([])
|
||||
|
||||
// Get current user data
|
||||
const currentUsername = computed(() => user.value?.username || '')
|
||||
const currentDisplayName = computed(() => user.value?.extra?.display_name || '')
|
||||
const currentPictureUrl = computed(() => user.value?.extra?.picture || '')
|
||||
|
||||
// Lightning domain
|
||||
const lightningDomain = computed(() =>
|
||||
import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
|
||||
)
|
||||
|
||||
// Computed previews
|
||||
const nip05Preview = computed(() => {
|
||||
const username = form.values.username || currentUsername.value || 'username'
|
||||
return `${username}@${lightningDomain.value}`
|
||||
})
|
||||
|
||||
const lightningAddress = computed(() => nip05Preview.value)
|
||||
|
||||
// Form schema
|
||||
const profileFormSchema = toTypedSchema(z.object({
|
||||
username: z.string()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(30, "Username must be less than 30 characters")
|
||||
.regex(/^[a-z0-9_-]+$/i, "Username can only contain letters, numbers, hyphens, and underscores"),
|
||||
display_name: z.string()
|
||||
.max(50, "Display name must be less than 50 characters")
|
||||
.optional(),
|
||||
picture: z.string().url("Invalid picture URL").optional().or(z.literal(''))
|
||||
}))
|
||||
|
||||
// Form setup
|
||||
const form = useForm({
|
||||
validationSchema: profileFormSchema,
|
||||
initialValues: {
|
||||
username: currentUsername.value,
|
||||
display_name: currentDisplayName.value,
|
||||
picture: currentPictureUrl.value
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for user changes and reset form
|
||||
watch(user, (newUser) => {
|
||||
if (newUser) {
|
||||
form.resetForm({
|
||||
values: {
|
||||
username: newUser.username || '',
|
||||
display_name: newUser.extra?.display_name || '',
|
||||
picture: newUser.extra?.picture || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const { meta } = form
|
||||
const isFormValid = computed(() => meta.value.valid)
|
||||
|
||||
// Form submit handler
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
await updateUserProfile(values)
|
||||
})
|
||||
|
||||
// Update user profile
|
||||
const updateUserProfile = async (formData: any) => {
|
||||
isUpdating.value = true
|
||||
updateError.value = null
|
||||
updateSuccess.value = false
|
||||
|
||||
try {
|
||||
// Get uploaded picture URL if a new picture was uploaded
|
||||
let pictureUrl = formData.picture || currentPictureUrl.value
|
||||
|
||||
if (uploadedPicture.value && uploadedPicture.value.length > 0) {
|
||||
const img = uploadedPicture.value[0]
|
||||
if (img.alias) {
|
||||
const imageUrl = imageService.getImageUrl(img.alias)
|
||||
if (imageUrl) {
|
||||
pictureUrl = imageUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
user_id: user.value?.id,
|
||||
username: formData.username,
|
||||
extra: {
|
||||
display_name: formData.display_name || '',
|
||||
picture: pictureUrl || ''
|
||||
}
|
||||
}
|
||||
|
||||
// Update profile via AuthService (which updates LNbits)
|
||||
await updateProfile(updateData)
|
||||
|
||||
// Broadcast to Nostr automatically
|
||||
try {
|
||||
await metadataService.publishMetadata()
|
||||
toast.success('Profile updated and broadcast to Nostr!')
|
||||
} catch (nostrError) {
|
||||
console.error('Failed to broadcast to Nostr:', nostrError)
|
||||
toast.warning('Profile updated, but failed to broadcast to Nostr')
|
||||
}
|
||||
|
||||
updateSuccess.value = true
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
updateSuccess.value = false
|
||||
}, 3000)
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to update profile'
|
||||
console.error('Error updating profile:', error)
|
||||
updateError.value = errorMessage
|
||||
toast.error(`Failed to update profile: ${errorMessage}`)
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Manually broadcast metadata to Nostr
|
||||
const broadcastMetadata = async () => {
|
||||
isBroadcasting.value = true
|
||||
|
||||
try {
|
||||
const result = await metadataService.publishMetadata()
|
||||
toast.success(`Profile broadcast to ${result.success}/${result.total} relays!`)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to broadcast metadata'
|
||||
console.error('Error broadcasting metadata:', error)
|
||||
toast.error(`Failed to broadcast: ${errorMessage}`)
|
||||
} finally {
|
||||
isBroadcasting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
39
src/modules/base/composables/useNostrMetadata.ts
Normal file
39
src/modules/base/composables/useNostrMetadata.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { NostrMetadataService, NostrMetadata } from '../nostr/nostr-metadata-service'
|
||||
|
||||
/**
|
||||
* Composable for accessing Nostr metadata service
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { publishMetadata, getMetadata } = useNostrMetadata()
|
||||
*
|
||||
* // Get current metadata
|
||||
* const metadata = getMetadata()
|
||||
*
|
||||
* // Publish metadata to Nostr relays
|
||||
* await publishMetadata()
|
||||
* ```
|
||||
*/
|
||||
export function useNostrMetadata() {
|
||||
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
|
||||
|
||||
/**
|
||||
* Publish user metadata to Nostr relays (NIP-01 kind 0)
|
||||
*/
|
||||
const publishMetadata = async (): Promise<{ success: number; total: number }> => {
|
||||
return await metadataService.publishMetadata()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's Nostr metadata
|
||||
*/
|
||||
const getMetadata = (): NostrMetadata => {
|
||||
return metadataService.getMetadata()
|
||||
}
|
||||
|
||||
return {
|
||||
publishMetadata,
|
||||
getMetadata
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import type { App } from 'vue'
|
|||
import type { ModulePlugin } from '@/core/types'
|
||||
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { relayHub } from './nostr/relay-hub'
|
||||
import { NostrMetadataService } from './nostr/nostr-metadata-service'
|
||||
|
||||
// Import auth services
|
||||
import { auth } from './auth/auth-service'
|
||||
|
|
@ -20,11 +21,13 @@ import { ImageUploadService } from './services/ImageUploadService'
|
|||
|
||||
// Import components
|
||||
import ImageUpload from './components/ImageUpload.vue'
|
||||
import ProfileSettings from './components/ProfileSettings.vue'
|
||||
|
||||
// Create service instances
|
||||
const invoiceService = new InvoiceService()
|
||||
const lnbitsAPI = new LnbitsAPI()
|
||||
const imageUploadService = new ImageUploadService()
|
||||
const nostrMetadataService = new NostrMetadataService()
|
||||
|
||||
/**
|
||||
* Base Module Plugin
|
||||
|
|
@ -39,7 +42,8 @@ export const baseModule: ModulePlugin = {
|
|||
|
||||
// Register core Nostr services
|
||||
container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub)
|
||||
|
||||
container.provide(SERVICE_TOKENS.NOSTR_METADATA_SERVICE, nostrMetadataService)
|
||||
|
||||
// Register auth service
|
||||
container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth)
|
||||
|
||||
|
|
@ -98,6 +102,10 @@ export const baseModule: ModulePlugin = {
|
|||
waitForDependencies: true, // ImageUploadService depends on ToastService
|
||||
maxRetries: 3
|
||||
})
|
||||
await nostrMetadataService.initialize({
|
||||
waitForDependencies: true, // NostrMetadataService depends on AuthService and RelayHub
|
||||
maxRetries: 3
|
||||
})
|
||||
// InvoiceService doesn't need initialization as it's not a BaseService
|
||||
|
||||
console.log('✅ Base module installed successfully')
|
||||
|
|
@ -114,13 +122,15 @@ export const baseModule: ModulePlugin = {
|
|||
await storageService.dispose()
|
||||
await toastService.dispose()
|
||||
await imageUploadService.dispose()
|
||||
await nostrMetadataService.dispose()
|
||||
// InvoiceService doesn't need disposal as it's not a BaseService
|
||||
await lnbitsAPI.dispose()
|
||||
|
||||
|
||||
// Remove services from DI container
|
||||
container.remove(SERVICE_TOKENS.LNBITS_API)
|
||||
container.remove(SERVICE_TOKENS.INVOICE_SERVICE)
|
||||
container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||
container.remove(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
|
||||
|
||||
console.log('✅ Base module uninstalled')
|
||||
},
|
||||
|
|
@ -134,7 +144,8 @@ export const baseModule: ModulePlugin = {
|
|||
toastService,
|
||||
invoiceService,
|
||||
pwaService,
|
||||
imageUploadService
|
||||
imageUploadService,
|
||||
nostrMetadataService
|
||||
},
|
||||
|
||||
// No routes - base module is pure infrastructure
|
||||
|
|
@ -142,8 +153,12 @@ export const baseModule: ModulePlugin = {
|
|||
|
||||
// Export components for use by other modules
|
||||
components: {
|
||||
ImageUpload
|
||||
ImageUpload,
|
||||
ProfileSettings
|
||||
}
|
||||
}
|
||||
|
||||
// Export components as named exports for direct import
|
||||
export { ImageUpload, ProfileSettings }
|
||||
|
||||
export default baseModule
|
||||
162
src/modules/base/nostr/nostr-metadata-service.ts
Normal file
162
src/modules/base/nostr/nostr-metadata-service.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { BaseService } from '@/core/base/BaseService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
|
||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
||||
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
||||
|
||||
/**
|
||||
* Nostr User Metadata (NIP-01 kind 0)
|
||||
* https://github.com/nostr-protocol/nips/blob/master/01.md
|
||||
*/
|
||||
export interface NostrMetadata {
|
||||
name?: string // Display name (from username)
|
||||
display_name?: string // Alternative display name
|
||||
about?: string // Bio/description
|
||||
picture?: string // Profile picture URL
|
||||
banner?: string // Profile banner URL
|
||||
nip05?: string // NIP-05 identifier (username@domain)
|
||||
lud16?: string // Lightning Address (same as nip05)
|
||||
website?: string // Personal website
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for publishing and managing Nostr user metadata (NIP-01 kind 0)
|
||||
*
|
||||
* This service handles:
|
||||
* - Publishing user profile metadata to Nostr relays
|
||||
* - Syncing LNbits user data with Nostr profile
|
||||
* - Auto-broadcasting metadata on login and profile updates
|
||||
*/
|
||||
export class NostrMetadataService extends BaseService {
|
||||
protected readonly metadata = {
|
||||
name: 'NostrMetadataService',
|
||||
version: '1.0.0',
|
||||
dependencies: ['AuthService', 'RelayHub']
|
||||
}
|
||||
|
||||
protected authService: AuthService | null = null
|
||||
protected relayHub: RelayHub | null = null
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
console.log('NostrMetadataService: Starting initialization...')
|
||||
|
||||
this.authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
|
||||
this.relayHub = injectService<RelayHub>(SERVICE_TOKENS.RELAY_HUB)
|
||||
|
||||
if (!this.authService) {
|
||||
throw new Error('AuthService not available')
|
||||
}
|
||||
|
||||
if (!this.relayHub) {
|
||||
throw new Error('RelayHub service not available')
|
||||
}
|
||||
|
||||
console.log('NostrMetadataService: Initialization complete')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Nostr metadata from LNbits user data
|
||||
*/
|
||||
private buildMetadata(): NostrMetadata {
|
||||
const user = this.authService?.user.value
|
||||
if (!user) {
|
||||
throw new Error('No authenticated user')
|
||||
}
|
||||
|
||||
const lightningDomain = import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
|
||||
const username = user.username || user.id.slice(0, 8)
|
||||
|
||||
const metadata: NostrMetadata = {
|
||||
name: username,
|
||||
nip05: `${username}@${lightningDomain}`,
|
||||
lud16: `${username}@${lightningDomain}`
|
||||
}
|
||||
|
||||
// Add optional fields from user.extra if they exist
|
||||
if (user.extra?.display_name) {
|
||||
metadata.display_name = user.extra.display_name
|
||||
}
|
||||
|
||||
if (user.extra?.picture) {
|
||||
metadata.picture = user.extra.picture
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish user metadata to Nostr relays (NIP-01 kind 0)
|
||||
*
|
||||
* This creates a replaceable event that updates the user's profile.
|
||||
* Only the latest kind 0 event for a given pubkey is kept by relays.
|
||||
*/
|
||||
async publishMetadata(): Promise<{ success: number; total: number }> {
|
||||
if (!this.authService?.isAuthenticated.value) {
|
||||
throw new Error('Must be authenticated to publish metadata')
|
||||
}
|
||||
|
||||
if (!this.relayHub?.isConnected.value) {
|
||||
throw new Error('Not connected to relays')
|
||||
}
|
||||
|
||||
const user = this.authService.user.value
|
||||
if (!user?.prvkey) {
|
||||
throw new Error('User private key not available')
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = this.buildMetadata()
|
||||
|
||||
console.log('📤 Publishing Nostr metadata (kind 0):', metadata)
|
||||
|
||||
// Create kind 0 event (user metadata)
|
||||
// Content is JSON-stringified metadata
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 0,
|
||||
content: JSON.stringify(metadata),
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
const privkeyBytes = this.hexToUint8Array(user.prvkey)
|
||||
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
||||
|
||||
console.log('✅ Metadata event signed:', signedEvent.id)
|
||||
console.log('📋 Full signed event:', JSON.stringify(signedEvent, null, 2))
|
||||
|
||||
// Publish to all connected relays
|
||||
const result = await this.relayHub.publishEvent(signedEvent)
|
||||
|
||||
console.log(`✅ Metadata published to ${result.success}/${result.total} relays`)
|
||||
|
||||
return result
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to publish metadata:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's Nostr metadata
|
||||
*/
|
||||
getMetadata(): NostrMetadata {
|
||||
return this.buildMetadata()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to convert hex string to Uint8Array
|
||||
*/
|
||||
private hexToUint8Array(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
protected async onDestroy(): Promise<void> {
|
||||
// Cleanup if needed
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue