refactor: Remove unused components and clean up identity management
- Delete IdentityDialog.vue, useIdentity.ts, useNostr.ts, useNostrFeed.ts, useNostrFeedPreloader.ts, useSocial.ts, and related Nostr client files to streamline the codebase. - Consolidate identity management and feed handling logic to improve maintainability and reduce complexity. - Ensure that the application remains functional while enhancing overall clarity for future development.
This commit is contained in:
parent
06bcc4b91e
commit
b074cc4ca3
11 changed files with 299 additions and 2184 deletions
|
|
@ -1,467 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Key, Download, Upload, Copy, Check } from 'lucide-vue-next'
|
|
||||||
import { identity } from '@/composables/useIdentity'
|
|
||||||
import { toast } from 'vue-sonner'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'update:isOpen', value: boolean): void
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
|
|
||||||
// Form states
|
|
||||||
const importKey = ref('')
|
|
||||||
const usePassword = ref(false)
|
|
||||||
const password = ref('')
|
|
||||||
const confirmPassword = ref('')
|
|
||||||
const showPrivateKey = ref(false)
|
|
||||||
const copied = ref(false)
|
|
||||||
|
|
||||||
// Profile form
|
|
||||||
const profileForm = ref({
|
|
||||||
name: '',
|
|
||||||
display_name: '',
|
|
||||||
about: '',
|
|
||||||
picture: '',
|
|
||||||
website: '',
|
|
||||||
lud16: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const passwordsMatch = computed(() => {
|
|
||||||
if (!usePassword.value) return true
|
|
||||||
return password.value === confirmPassword.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const canGenerate = computed(() => {
|
|
||||||
if (!usePassword.value) return true
|
|
||||||
return password.value.length >= 6 && passwordsMatch.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const canImport = computed(() => {
|
|
||||||
return importKey.value.trim().length > 0 && canGenerate.value
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleGenerate() {
|
|
||||||
try {
|
|
||||||
const pwd = usePassword.value ? password.value : undefined
|
|
||||||
await identity.generateNewIdentity(pwd)
|
|
||||||
|
|
||||||
// Initialize profile form with current data
|
|
||||||
const profile = identity.currentProfile.value
|
|
||||||
if (profile) {
|
|
||||||
profileForm.value = {
|
|
||||||
name: profile.name || '',
|
|
||||||
display_name: profile.display_name || '',
|
|
||||||
about: profile.about || '',
|
|
||||||
picture: profile.picture || '',
|
|
||||||
website: profile.website || '',
|
|
||||||
lud16: profile.lud16 || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Identity generated successfully!')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate identity:', error)
|
|
||||||
toast.error('Failed to generate identity')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleImport() {
|
|
||||||
try {
|
|
||||||
const pwd = usePassword.value ? password.value : undefined
|
|
||||||
await identity.importIdentity(importKey.value.trim(), pwd)
|
|
||||||
|
|
||||||
// Initialize profile form with current data
|
|
||||||
const profile = identity.currentProfile.value
|
|
||||||
if (profile) {
|
|
||||||
profileForm.value = {
|
|
||||||
name: profile.name || '',
|
|
||||||
display_name: profile.display_name || '',
|
|
||||||
about: profile.about || '',
|
|
||||||
picture: profile.picture || '',
|
|
||||||
website: profile.website || '',
|
|
||||||
lud16: profile.lud16 || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Identity imported successfully!')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to import identity:', error)
|
|
||||||
toast.error('Failed to import identity')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateProfile() {
|
|
||||||
try {
|
|
||||||
// Filter out empty values
|
|
||||||
const profileData = Object.fromEntries(
|
|
||||||
Object.entries(profileForm.value).filter(([_, value]) => value.trim() !== '')
|
|
||||||
)
|
|
||||||
|
|
||||||
await identity.updateProfile(profileData)
|
|
||||||
toast.success('Profile updated successfully!')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update profile:', error)
|
|
||||||
toast.error('Failed to update profile')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyToClipboard(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
copied.value = true
|
|
||||||
setTimeout(() => copied.value = false, 2000)
|
|
||||||
toast.success('Copied to clipboard!')
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to copy to clipboard')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
emit('update:isOpen', false)
|
|
||||||
// Reset forms
|
|
||||||
importKey.value = ''
|
|
||||||
password.value = ''
|
|
||||||
confirmPassword.value = ''
|
|
||||||
usePassword.value = false
|
|
||||||
showPrivateKey.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize profile form when dialog opens
|
|
||||||
function initializeProfileForm() {
|
|
||||||
const profile = identity.currentProfile.value
|
|
||||||
if (profile) {
|
|
||||||
profileForm.value = {
|
|
||||||
name: profile.name || '',
|
|
||||||
display_name: profile.display_name || '',
|
|
||||||
about: profile.about || '',
|
|
||||||
picture: profile.picture || '',
|
|
||||||
website: profile.website || '',
|
|
||||||
lud16: profile.lud16 || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Dialog :open="isOpen" @update:open="handleClose">
|
|
||||||
<DialogContent class="w-[95vw] sm:max-w-[600px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle class="flex items-center gap-2">
|
|
||||||
<Key class="w-5 h-5" />
|
|
||||||
Nostr Identity
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Manage your Nostr identity and profile
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Tabs default-value="identity" class="w-full" @update:model-value="initializeProfileForm">
|
|
||||||
<TabsList class="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="identity">Identity</TabsTrigger>
|
|
||||||
<TabsTrigger value="profile" :disabled="!identity.isAuthenticated.value">
|
|
||||||
Profile
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="identity" class="space-y-4">
|
|
||||||
<!-- Current Identity Display -->
|
|
||||||
<div v-if="identity.isAuthenticated.value" class="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="text-lg">Current Identity</CardTitle>
|
|
||||||
<CardDescription>Your active Nostr identity</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-3">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label class="text-sm font-medium">Public Key (npub)</Label>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
:model-value="identity.identityInfo.value?.npub || ''"
|
|
||||||
readonly
|
|
||||||
class="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
@click="copyToClipboard(identity.identityInfo.value?.npub || '')"
|
|
||||||
>
|
|
||||||
<Copy class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<Label class="text-sm font-medium">Private Key (nsec)</Label>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
@click="showPrivateKey = !showPrivateKey"
|
|
||||||
>
|
|
||||||
{{ showPrivateKey ? 'Hide' : 'Show' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
:type="showPrivateKey ? 'text' : 'password'"
|
|
||||||
:model-value="identity.currentIdentity.value?.nsec || ''"
|
|
||||||
readonly
|
|
||||||
class="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
@click="copyToClipboard(identity.currentIdentity.value?.nsec || '')"
|
|
||||||
>
|
|
||||||
<component :is="copied ? Check : Copy" class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
⚠️ Keep your private key safe! Anyone with access can control your identity.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="destructive" size="sm" @click="identity.signOut()">
|
|
||||||
Sign Out
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Identity Setup -->
|
|
||||||
<div v-else class="space-y-4">
|
|
||||||
<Tabs default-value="generate" class="w-full">
|
|
||||||
<TabsList class="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="generate">
|
|
||||||
<Download class="w-4 h-4 mr-2" />
|
|
||||||
Generate New
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="import">
|
|
||||||
<Upload class="w-4 h-4 mr-2" />
|
|
||||||
Import Existing
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="generate" class="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Generate New Identity</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Create a new Nostr identity with a fresh keypair
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
id="use-password"
|
|
||||||
type="checkbox"
|
|
||||||
v-model="usePassword"
|
|
||||||
class="rounded"
|
|
||||||
/>
|
|
||||||
<Label for="use-password">Encrypt with password</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="usePassword" class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label for="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
v-model="password"
|
|
||||||
placeholder="Enter password (min 6 characters)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label for="confirm-password">Confirm Password</Label>
|
|
||||||
<Input
|
|
||||||
id="confirm-password"
|
|
||||||
type="password"
|
|
||||||
v-model="confirmPassword"
|
|
||||||
placeholder="Confirm password"
|
|
||||||
:class="{ 'border-destructive': !passwordsMatch && confirmPassword.length > 0 }"
|
|
||||||
/>
|
|
||||||
<p v-if="!passwordsMatch && confirmPassword.length > 0" class="text-xs text-destructive mt-1">
|
|
||||||
Passwords do not match
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
@click="handleGenerate"
|
|
||||||
:disabled="!canGenerate || identity.isLoading.value"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
Generate Identity
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="import" class="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Import Existing Identity</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Import your existing Nostr identity using your private key
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label for="import-key">Private Key</Label>
|
|
||||||
<Textarea
|
|
||||||
id="import-key"
|
|
||||||
v-model="importKey"
|
|
||||||
placeholder="Enter your private key (hex format or nsec...)"
|
|
||||||
class="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
id="use-password-import"
|
|
||||||
type="checkbox"
|
|
||||||
v-model="usePassword"
|
|
||||||
class="rounded"
|
|
||||||
/>
|
|
||||||
<Label for="use-password-import">Encrypt with password</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="usePassword" class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label for="password-import">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="password-import"
|
|
||||||
type="password"
|
|
||||||
v-model="password"
|
|
||||||
placeholder="Enter password (min 6 characters)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label for="confirm-password-import">Confirm Password</Label>
|
|
||||||
<Input
|
|
||||||
id="confirm-password-import"
|
|
||||||
type="password"
|
|
||||||
v-model="confirmPassword"
|
|
||||||
placeholder="Confirm password"
|
|
||||||
:class="{ 'border-destructive': !passwordsMatch && confirmPassword.length > 0 }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
@click="handleImport"
|
|
||||||
:disabled="!canImport || identity.isLoading.value"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
Import Identity
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="profile" class="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Profile Information</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Update your public Nostr profile information
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label for="name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
v-model="profileForm.name"
|
|
||||||
placeholder="Your name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label for="display-name">Display Name</Label>
|
|
||||||
<Input
|
|
||||||
id="display-name"
|
|
||||||
v-model="profileForm.display_name"
|
|
||||||
placeholder="Display name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label for="about">About</Label>
|
|
||||||
<Textarea
|
|
||||||
id="about"
|
|
||||||
v-model="profileForm.about"
|
|
||||||
placeholder="Tell people about yourself..."
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label for="picture">Picture URL</Label>
|
|
||||||
<Input
|
|
||||||
id="picture"
|
|
||||||
v-model="profileForm.picture"
|
|
||||||
placeholder="https://example.com/your-avatar.jpg"
|
|
||||||
type="url"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label for="website">Website</Label>
|
|
||||||
<Input
|
|
||||||
id="website"
|
|
||||||
v-model="profileForm.website"
|
|
||||||
placeholder="https://your-website.com"
|
|
||||||
type="url"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label for="lightning">Lightning Address</Label>
|
|
||||||
<Input
|
|
||||||
id="lightning"
|
|
||||||
v-model="profileForm.lud16"
|
|
||||||
placeholder="you@getalby.com"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
|
||||||
Lightning address for receiving zaps
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
@click="handleUpdateProfile"
|
|
||||||
:disabled="identity.isLoading.value"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
Update Profile
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div v-if="identity.error.value" class="text-sm text-destructive">
|
|
||||||
{{ identity.error.value }}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,87 +1,35 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { Megaphone } from 'lucide-vue-next'
|
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||||
import { config } from '@/lib/config'
|
import { config, configUtils } from '@/lib/config'
|
||||||
import { useNostrFeedPreloader } from '@/composables/useNostrFeedPreloader'
|
import { useRelayHub } from '@/composables/useRelayHub'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
feedType?: 'all' | 'announcements' | 'events' | 'general'
|
feedType?: 'all' | 'announcements' | 'events' | 'general'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const feedPreloader = useNostrFeedPreloader({
|
const relayHub = useRelayHub()
|
||||||
relays: props.relays,
|
|
||||||
feedType: props.feedType,
|
|
||||||
limit: 50,
|
|
||||||
includeReplies: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
// Reactive state
|
||||||
notes,
|
const notes = ref<any[]>([])
|
||||||
isLoading,
|
const isLoading = ref(false)
|
||||||
error,
|
const error = ref<Error | null>(null)
|
||||||
isPreloading,
|
const isConnected = ref(false)
|
||||||
isPreloaded,
|
|
||||||
preloadError,
|
|
||||||
shouldShowLoading,
|
|
||||||
loadNotes,
|
|
||||||
connectToFeed,
|
|
||||||
subscribeToFeedUpdates,
|
|
||||||
cleanup
|
|
||||||
} = feedPreloader
|
|
||||||
|
|
||||||
// Check if we need to load feed data
|
|
||||||
const needsToLoadFeed = computed(() => {
|
|
||||||
return !isPreloaded.value &&
|
|
||||||
!isPreloading.value &&
|
|
||||||
notes.value.length === 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadFeed = async () => {
|
|
||||||
try {
|
|
||||||
console.log('Connecting to feed...')
|
|
||||||
await connectToFeed()
|
|
||||||
console.log('Connected to feed')
|
|
||||||
|
|
||||||
console.log('Loading feed...')
|
|
||||||
await loadNotes()
|
|
||||||
console.log('Feed loaded')
|
|
||||||
|
|
||||||
// Subscribe to real-time updates
|
|
||||||
subscribeToFeedUpdates()
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load feed:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryLoadFeed = async () => {
|
|
||||||
try {
|
|
||||||
console.log('Refreshing feed...')
|
|
||||||
await connectToFeed()
|
|
||||||
await loadNotes(true) // Pass true to indicate this is a refresh
|
|
||||||
console.log('Feed refreshed')
|
|
||||||
|
|
||||||
// Subscribe to real-time updates
|
|
||||||
subscribeToFeedUpdates()
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh feed:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get admin/moderator pubkeys from centralized config
|
// Get admin/moderator pubkeys from centralized config
|
||||||
const adminPubkeys = config.nostr.adminPubkeys
|
const adminPubkeys = config.nostr.adminPubkeys
|
||||||
|
|
||||||
function isAdminPost(pubkey: string): boolean {
|
// Check if we have admin pubkeys configured
|
||||||
return adminPubkeys.includes(pubkey)
|
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
|
||||||
}
|
|
||||||
|
|
||||||
function getFeedTitle(): string {
|
// Get feed title and description based on type
|
||||||
|
const feedTitle = computed(() => {
|
||||||
switch (props.feedType) {
|
switch (props.feedType) {
|
||||||
case 'announcements':
|
case 'announcements':
|
||||||
return 'Community Announcements'
|
return 'Community Announcements'
|
||||||
|
|
@ -92,9 +40,9 @@ function getFeedTitle(): string {
|
||||||
default:
|
default:
|
||||||
return 'Community Feed'
|
return 'Community Feed'
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
function getFeedDescription(): string {
|
const feedDescription = computed(() => {
|
||||||
switch (props.feedType) {
|
switch (props.feedType) {
|
||||||
case 'announcements':
|
case 'announcements':
|
||||||
return 'Important announcements from community administrators'
|
return 'Important announcements from community administrators'
|
||||||
|
|
@ -105,108 +53,284 @@ function getFeedDescription(): string {
|
||||||
default:
|
default:
|
||||||
return 'Latest posts from the community'
|
return 'Latest posts from the community'
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if a post is from an admin
|
||||||
|
function isAdminPost(pubkey: string): boolean {
|
||||||
|
return configUtils.isAdminPubkey(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
// Load notes from relays
|
||||||
// Only load feed if it hasn't been preloaded
|
async function loadNotes() {
|
||||||
if (needsToLoadFeed.value) {
|
if (!hasAdminPubkeys.value && props.feedType === 'announcements') {
|
||||||
console.log('Feed not preloaded, loading now...')
|
notes.value = []
|
||||||
loadFeed()
|
return
|
||||||
} else if (isPreloaded.value) {
|
|
||||||
console.log('Feed was preloaded, subscribing to updates...')
|
|
||||||
// Subscribe to real-time updates if feed was preloaded
|
|
||||||
subscribeToFeedUpdates()
|
|
||||||
} else {
|
|
||||||
console.log('Feed data is ready, no additional loading needed')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
// Connect to relay hub if not already connected
|
||||||
|
if (!relayHub.isConnected.value) {
|
||||||
|
await relayHub.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected.value = relayHub.isConnected.value
|
||||||
|
|
||||||
|
if (!isConnected.value) {
|
||||||
|
throw new Error('Failed to connect to Nostr relays')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure filters based on feed type
|
||||||
|
const filters: any[] = [{
|
||||||
|
kinds: [1], // TEXT_NOTE
|
||||||
|
limit: 50
|
||||||
|
}]
|
||||||
|
|
||||||
|
// Filter by authors for announcements
|
||||||
|
if (props.feedType === 'announcements' && hasAdminPubkeys.value) {
|
||||||
|
filters[0].authors = adminPubkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query events from relays
|
||||||
|
const events = await relayHub.queryEvents(filters)
|
||||||
|
|
||||||
|
// Process and filter events
|
||||||
|
let processedNotes = events.map(event => ({
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
content: event.content,
|
||||||
|
created_at: event.created_at,
|
||||||
|
tags: event.tags || [],
|
||||||
|
// Extract mentions from tags
|
||||||
|
mentions: event.tags?.filter(tag => tag[0] === 'p').map(tag => tag[1]) || [],
|
||||||
|
// Check if it's a reply
|
||||||
|
isReply: event.tags?.some(tag => tag[0] === 'e' && tag[3] === 'reply'),
|
||||||
|
replyTo: event.tags?.find(tag => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Sort by creation time (newest first)
|
||||||
|
processedNotes.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
|
||||||
|
// For general feed, exclude admin posts
|
||||||
|
if (props.feedType === 'general' && hasAdminPubkeys.value) {
|
||||||
|
processedNotes = processedNotes.filter(note => !isAdminPost(note.pubkey))
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.value = processedNotes
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errorObj = err instanceof Error ? err : new Error('Failed to load notes')
|
||||||
|
error.value = errorObj
|
||||||
|
console.error('Failed to load notes:', errorObj)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the feed
|
||||||
|
async function refreshFeed() {
|
||||||
|
await loadNotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to real-time updates
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
async function startRealtimeSubscription() {
|
||||||
|
if (!relayHub.isConnected.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filters: any[] = [{
|
||||||
|
kinds: [1], // TEXT_NOTE
|
||||||
|
limit: 10
|
||||||
|
}]
|
||||||
|
|
||||||
|
if (props.feedType === 'announcements' && hasAdminPubkeys.value) {
|
||||||
|
filters[0].authors = adminPubkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe = relayHub.subscribe({
|
||||||
|
id: `feed-${props.feedType || 'all'}`,
|
||||||
|
filters,
|
||||||
|
onEvent: (event) => {
|
||||||
|
// Add new note to the beginning of the list
|
||||||
|
const newNote = {
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
content: event.content,
|
||||||
|
created_at: event.created_at,
|
||||||
|
tags: event.tags || [],
|
||||||
|
mentions: event.tags?.filter(tag => tag[0] === 'p').map(tag => tag[1]) || [],
|
||||||
|
isReply: event.tags?.some(tag => tag[0] === 'e' && tag[3] === 'reply'),
|
||||||
|
replyTo: event.tags?.find(tag => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if note should be included
|
||||||
|
let shouldInclude = true
|
||||||
|
if (props.feedType === 'announcements' && !isAdminPost(event.pubkey)) {
|
||||||
|
shouldInclude = false
|
||||||
|
}
|
||||||
|
if (props.feedType === 'general' && isAdminPost(event.pubkey)) {
|
||||||
|
shouldInclude = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldInclude) {
|
||||||
|
notes.value.unshift(newNote)
|
||||||
|
// Limit array size
|
||||||
|
if (notes.value.length > 100) {
|
||||||
|
notes.value = notes.value.slice(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start real-time subscription:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup subscription
|
||||||
|
function cleanup() {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
unsubscribe = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadNotes()
|
||||||
|
await startRealtimeSubscription()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatDate(timestamp: number): string {
|
|
||||||
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full max-w-3xl mx-auto">
|
<Card class="w-full">
|
||||||
<Card class="w-full">
|
<CardHeader>
|
||||||
<CardHeader>
|
<div class="flex items-center justify-between">
|
||||||
<CardTitle class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Megaphone v-if="feedType === 'announcements'" class="h-5 w-5" />
|
<Megaphone class="h-5 w-5 text-primary" />
|
||||||
{{ getFeedTitle() }}
|
<div>
|
||||||
</CardTitle>
|
<CardTitle>{{ feedTitle }}</CardTitle>
|
||||||
<CardDescription>{{ getFeedDescription() }}</CardDescription>
|
<CardDescription>{{ feedDescription }}</CardDescription>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ScrollArea class="h-[600px] w-full pr-4">
|
|
||||||
<div v-if="shouldShowLoading" class="flex items-center justify-center py-8">
|
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div v-else-if="error || preloadError" class="text-center py-8 text-destructive">
|
<Button
|
||||||
{{ (error || preloadError)?.message }}
|
variant="outline"
|
||||||
</div>
|
size="sm"
|
||||||
|
@click="refreshFeed"
|
||||||
<div v-else-if="notes.length === 0 && !isLoading && !isPreloading" class="text-center py-8 text-muted-foreground">
|
:disabled="isLoading"
|
||||||
<div v-if="feedType === 'announcements' && adminPubkeys.length === 0" class="space-y-2">
|
class="gap-2"
|
||||||
<p>No admin pubkeys configured</p>
|
|
||||||
<p class="text-xs">Set VITE_ADMIN_PUBKEYS environment variable</p>
|
|
||||||
</div>
|
|
||||||
<p v-else>No {{ feedType || 'notes' }} found</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="notes.length > 0" class="space-y-4">
|
|
||||||
<Card
|
|
||||||
v-for="note in notes"
|
|
||||||
:key="note.id"
|
|
||||||
:class="[
|
|
||||||
'p-4 transition-all',
|
|
||||||
isAdminPost(note.pubkey) && feedType !== 'announcements'
|
|
||||||
? 'border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950'
|
|
||||||
: ''
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium text-sm">{{ note.pubkey.slice(0, 8) }}...</span>
|
|
||||||
<Badge
|
|
||||||
v-if="isAdminPost(note.pubkey)"
|
|
||||||
variant="secondary"
|
|
||||||
class="text-xs bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
|
||||||
>
|
|
||||||
<Megaphone class="h-3 w-3 mr-1" />
|
|
||||||
Admin
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-muted-foreground">{{ formatDate(note.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm whitespace-pre-wrap">{{ note.content }}</p>
|
|
||||||
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span>{{ note.replyCount }} replies</span>
|
|
||||||
<span>{{ note.reactionCount }} reactions</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter class="flex justify-between">
|
|
||||||
<button
|
|
||||||
class="text-sm text-primary hover:underline flex items-center gap-2"
|
|
||||||
:disabled="isLoading || isPreloading"
|
|
||||||
@click="retryLoadFeed"
|
|
||||||
>
|
>
|
||||||
<span v-if="isLoading || isPreloading" class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></span>
|
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</Button>
|
||||||
<span v-if="adminPubkeys.length > 0" class="text-xs text-muted-foreground">
|
</div>
|
||||||
{{ adminPubkeys.length }} admin(s) configured
|
</CardHeader>
|
||||||
</span>
|
|
||||||
</CardFooter>
|
<CardContent>
|
||||||
</Card>
|
<!-- Connection Status -->
|
||||||
</div>
|
<div v-if="!isConnected && !isLoading" class="mb-4 p-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||||
|
<div class="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
|
||||||
|
<AlertCircle class="h-4 w-4" />
|
||||||
|
<span class="text-sm">Not connected to relays</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<RefreshCw class="h-4 w-4 animate-spin" />
|
||||||
|
<span class="text-muted-foreground">Loading announcements...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="error" class="text-center py-8">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-destructive mb-4">
|
||||||
|
<AlertCircle class="h-5 w-5" />
|
||||||
|
<span>Failed to load announcements</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">{{ error.message }}</p>
|
||||||
|
<Button @click="refreshFeed" variant="outline">Try Again</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Admin Pubkeys Warning -->
|
||||||
|
<div v-else-if="!hasAdminPubkeys && props.feedType === 'announcements'" class="text-center py-8">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
|
||||||
|
<Megaphone class="h-5 w-5" />
|
||||||
|
<span>No admin pubkeys configured</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Community announcements will appear here once admin pubkeys are configured.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Notes -->
|
||||||
|
<div v-else-if="notes.length === 0" class="text-center py-8">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
|
||||||
|
<Megaphone class="h-5 w-5" />
|
||||||
|
<span>No announcements yet</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Check back later for community updates and announcements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes List -->
|
||||||
|
<ScrollArea v-else class="h-[400px] pr-4">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="note in notes"
|
||||||
|
:key="note.id"
|
||||||
|
class="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<!-- Note Header -->
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
v-if="isAdminPost(note.pubkey)"
|
||||||
|
variant="default"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="note.isReply"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
Reply
|
||||||
|
</Badge>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note Content -->
|
||||||
|
<div class="text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{{ note.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note Footer -->
|
||||||
|
<div v-if="note.mentions.length > 0" class="mt-2 pt-2 border-t">
|
||||||
|
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<span>Mentions:</span>
|
||||||
|
<span v-for="mention in note.mentions.slice(0, 3)" :key="mention" class="font-mono">
|
||||||
|
{{ mention.slice(0, 8) }}...
|
||||||
|
</span>
|
||||||
|
<span v-if="note.mentions.length > 3" class="text-muted-foreground">
|
||||||
|
+{{ note.mentions.length - 3 }} more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { IdentityManager, type NostrIdentity, type NostrProfile } from '@/lib/nostr/identity'
|
|
||||||
|
|
||||||
const currentIdentity = ref<NostrIdentity | null>(null)
|
|
||||||
const currentProfile = ref<NostrProfile | null>(null)
|
|
||||||
const isAuthenticated = computed(() => !!currentIdentity.value)
|
|
||||||
|
|
||||||
export function useIdentity() {
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize identity on app start
|
|
||||||
*/
|
|
||||||
async function initialize(password?: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const identity = await IdentityManager.loadIdentity(password)
|
|
||||||
if (identity) {
|
|
||||||
currentIdentity.value = identity
|
|
||||||
currentProfile.value = IdentityManager.loadProfile()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to initialize identity'
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate new identity
|
|
||||||
*/
|
|
||||||
async function generateNewIdentity(password?: string): Promise<NostrIdentity> {
|
|
||||||
try {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const identity = IdentityManager.generateIdentity()
|
|
||||||
await IdentityManager.saveIdentity(identity, password)
|
|
||||||
|
|
||||||
currentIdentity.value = identity
|
|
||||||
currentProfile.value = null
|
|
||||||
|
|
||||||
return identity
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to generate identity'
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import existing identity
|
|
||||||
*/
|
|
||||||
async function importIdentity(privateKey: string, password?: string): Promise<NostrIdentity> {
|
|
||||||
try {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const identity = IdentityManager.importIdentity(privateKey)
|
|
||||||
await IdentityManager.saveIdentity(identity, password)
|
|
||||||
|
|
||||||
currentIdentity.value = identity
|
|
||||||
currentProfile.value = IdentityManager.loadProfile()
|
|
||||||
|
|
||||||
return identity
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to import identity'
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user profile
|
|
||||||
*/
|
|
||||||
async function updateProfile(profile: NostrProfile): Promise<void> {
|
|
||||||
try {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
IdentityManager.saveProfile(profile)
|
|
||||||
currentProfile.value = profile
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to update profile'
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign out and clear identity
|
|
||||||
*/
|
|
||||||
function signOut(): void {
|
|
||||||
IdentityManager.clearIdentity()
|
|
||||||
currentIdentity.value = null
|
|
||||||
currentProfile.value = null
|
|
||||||
error.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if identity is stored
|
|
||||||
*/
|
|
||||||
function hasStoredIdentity(): boolean {
|
|
||||||
return IdentityManager.hasStoredIdentity()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if stored identity is encrypted
|
|
||||||
*/
|
|
||||||
function isStoredIdentityEncrypted(): boolean {
|
|
||||||
return IdentityManager.isStoredIdentityEncrypted()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load identity with password (for encrypted identities)
|
|
||||||
*/
|
|
||||||
async function loadWithPassword(password: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const identity = await IdentityManager.loadIdentity(password)
|
|
||||||
if (identity) {
|
|
||||||
currentIdentity.value = identity
|
|
||||||
currentProfile.value = IdentityManager.loadProfile()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to load identity'
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current identity info
|
|
||||||
*/
|
|
||||||
const identityInfo = computed(() => {
|
|
||||||
if (!currentIdentity.value) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
npub: currentIdentity.value.npub,
|
|
||||||
publicKey: currentIdentity.value.publicKey,
|
|
||||||
shortPubkey: currentIdentity.value.publicKey.slice(0, 8) + '...' + currentIdentity.value.publicKey.slice(-8)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get profile display info
|
|
||||||
*/
|
|
||||||
const profileDisplay = computed(() => {
|
|
||||||
if (!currentProfile.value && !identityInfo.value) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: currentProfile.value?.name || currentProfile.value?.display_name || identityInfo.value?.shortPubkey || 'Anonymous',
|
|
||||||
displayName: currentProfile.value?.display_name || currentProfile.value?.name,
|
|
||||||
about: currentProfile.value?.about,
|
|
||||||
picture: currentProfile.value?.picture,
|
|
||||||
website: currentProfile.value?.website,
|
|
||||||
lightningAddress: currentProfile.value?.lud16
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
currentIdentity: computed(() => currentIdentity.value),
|
|
||||||
currentProfile: computed(() => currentProfile.value),
|
|
||||||
isAuthenticated,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
identityInfo,
|
|
||||||
profileDisplay,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
initialize,
|
|
||||||
generateNewIdentity,
|
|
||||||
importIdentity,
|
|
||||||
updateProfile,
|
|
||||||
signOut,
|
|
||||||
hasStoredIdentity,
|
|
||||||
isStoredIdentityEncrypted,
|
|
||||||
loadWithPassword
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance for global state
|
|
||||||
export const identity = useIdentity()
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
|
||||||
import type { NostrClientConfig } from '@/lib/nostr/client'
|
|
||||||
|
|
||||||
export function useNostr(config?: NostrClientConfig) {
|
|
||||||
const store = useNostrStore()
|
|
||||||
|
|
||||||
// If custom relays are provided, update the store
|
|
||||||
if (config?.relays) {
|
|
||||||
store.setRelayUrls(config.relays)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return reactive refs from the store
|
|
||||||
const { isConnected, isConnecting, error } = storeToRefs(store)
|
|
||||||
|
|
||||||
return {
|
|
||||||
isConnected,
|
|
||||||
isConnecting,
|
|
||||||
error,
|
|
||||||
connect: store.connect,
|
|
||||||
disconnect: store.disconnect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
import { ref, readonly } from 'vue'
|
|
||||||
import type { NostrNote } from '@/lib/nostr/client'
|
|
||||||
import { useRelayHub } from '@/composables/useRelayHub'
|
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
|
||||||
import { config as globalConfig } from '@/lib/config'
|
|
||||||
import { notificationManager } from '@/lib/notifications/manager'
|
|
||||||
|
|
||||||
export interface NostrFeedConfig {
|
|
||||||
relays?: string[]
|
|
||||||
feedType?: 'all' | 'announcements' | 'events' | 'general'
|
|
||||||
limit?: number
|
|
||||||
includeReplies?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useNostrFeed(config: NostrFeedConfig = {}) {
|
|
||||||
const relayHub = useRelayHub()
|
|
||||||
const nostrStore = useNostrStore()
|
|
||||||
|
|
||||||
// State
|
|
||||||
const notes = ref<NostrNote[]>([])
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const error = ref<Error | null>(null)
|
|
||||||
const isConnected = ref(false)
|
|
||||||
|
|
||||||
// Get admin/moderator pubkeys from centralized config
|
|
||||||
const adminPubkeys = globalConfig.nostr?.adminPubkeys || []
|
|
||||||
|
|
||||||
// Track last seen note timestamp to avoid duplicate notifications
|
|
||||||
let lastSeenTimestamp = Math.floor(Date.now() / 1000)
|
|
||||||
|
|
||||||
// Load notes from localStorage immediately (synchronous)
|
|
||||||
const loadFromStorage = () => {
|
|
||||||
const storageKey = `nostr-feed-${config.feedType || 'all'}`
|
|
||||||
const storedNotes = localStorage.getItem(storageKey)
|
|
||||||
|
|
||||||
if (storedNotes) {
|
|
||||||
try {
|
|
||||||
const parsedNotes = JSON.parse(storedNotes) as NostrNote[]
|
|
||||||
notes.value = parsedNotes
|
|
||||||
console.log(`Loaded ${parsedNotes.length} notes from localStorage`)
|
|
||||||
|
|
||||||
// Update last seen timestamp from stored notes
|
|
||||||
if (notes.value.length > 0) {
|
|
||||||
lastSeenTimestamp = Math.max(lastSeenTimestamp, Math.max(...notes.value.map(note => note.created_at)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to parse stored notes:', err)
|
|
||||||
localStorage.removeItem(storageKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load notes from localStorage first, then fetch new ones
|
|
||||||
const loadNotes = async (isRefresh = false) => {
|
|
||||||
try {
|
|
||||||
// First, try to load from localStorage immediately (only if not refreshing)
|
|
||||||
if (!isRefresh) {
|
|
||||||
const hasStoredData = loadFromStorage()
|
|
||||||
|
|
||||||
// Only show loading if we don't have stored data
|
|
||||||
if (!hasStoredData) {
|
|
||||||
isLoading.value = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For refresh, always show loading
|
|
||||||
isLoading.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
// Connect to Nostr using the centralized relay hub
|
|
||||||
await relayHub.connect()
|
|
||||||
isConnected.value = relayHub.isConnected.value
|
|
||||||
|
|
||||||
if (!isConnected.value) {
|
|
||||||
throw new Error('Failed to connect to Nostr relays')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure fetch options based on feed type
|
|
||||||
const fetchOptions: any = {
|
|
||||||
limit: config.limit || 50,
|
|
||||||
includeReplies: config.includeReplies || false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by authors based on feed type
|
|
||||||
if (config.feedType === 'announcements') {
|
|
||||||
if (adminPubkeys.length > 0) {
|
|
||||||
fetchOptions.authors = adminPubkeys
|
|
||||||
} else {
|
|
||||||
notes.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch new notes using the relay hub
|
|
||||||
const newNotes = await relayHub.queryEvents([
|
|
||||||
{
|
|
||||||
kinds: [1], // TEXT_NOTE
|
|
||||||
limit: fetchOptions.limit,
|
|
||||||
authors: fetchOptions.authors
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// Client-side filtering for 'general' feed (exclude admin posts)
|
|
||||||
let filteredNotes = newNotes
|
|
||||||
if (config.feedType === 'general' && adminPubkeys.length > 0) {
|
|
||||||
filteredNotes = newNotes.filter(note => !adminPubkeys.includes(note.pubkey))
|
|
||||||
}
|
|
||||||
|
|
||||||
// For refresh, replace all notes. For normal load, merge with existing
|
|
||||||
if (isRefresh) {
|
|
||||||
notes.value = filteredNotes
|
|
||||||
} else {
|
|
||||||
// Merge with existing notes, avoiding duplicates
|
|
||||||
const existingIds = new Set(notes.value.map(note => note.id))
|
|
||||||
const uniqueNewNotes = filteredNotes.filter(note => !existingIds.has(note.id))
|
|
||||||
|
|
||||||
if (uniqueNewNotes.length > 0) {
|
|
||||||
// Add new notes to the beginning
|
|
||||||
notes.value.unshift(...uniqueNewNotes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit the array size to prevent memory issues
|
|
||||||
if (notes.value.length > 100) {
|
|
||||||
notes.value = notes.value.slice(0, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to localStorage
|
|
||||||
const storageKey = `nostr-feed-${config.feedType || 'all'}`
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(notes.value))
|
|
||||||
console.log(`Loaded ${notes.value.length} notes`)
|
|
||||||
|
|
||||||
// Update last seen timestamp
|
|
||||||
if (notes.value.length > 0) {
|
|
||||||
lastSeenTimestamp = Math.max(lastSeenTimestamp, Math.max(...notes.value.map(note => note.created_at)))
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err : new Error('Failed to load notes')
|
|
||||||
console.error('Error loading notes:', err)
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Real-time subscription for new notes
|
|
||||||
let unsubscribe: (() => void) | null = null
|
|
||||||
|
|
||||||
const subscribeToFeedUpdates = () => {
|
|
||||||
try {
|
|
||||||
// Subscribe to real-time notes using the relay hub
|
|
||||||
unsubscribe = relayHub.subscribe({
|
|
||||||
id: `feed-${config.feedType || 'all'}`,
|
|
||||||
filters: [{ kinds: [1] }], // TEXT_NOTE
|
|
||||||
onEvent: (event: any) => {
|
|
||||||
// Only process notes newer than last seen
|
|
||||||
if (event.created_at > lastSeenTimestamp) {
|
|
||||||
// Check if note should be included based on feed type
|
|
||||||
const shouldInclude = shouldIncludeNote(event)
|
|
||||||
if (shouldInclude) {
|
|
||||||
// Add to beginning of notes array
|
|
||||||
notes.value.unshift(event)
|
|
||||||
|
|
||||||
// Limit the array size to prevent memory issues
|
|
||||||
if (notes.value.length > 100) {
|
|
||||||
notes.value = notes.value.slice(0, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to localStorage
|
|
||||||
const storageKey = `nostr-feed-${config.feedType || 'all'}`
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(notes.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send notification if appropriate (only for admin announcements when not in announcements feed)
|
|
||||||
if (config.feedType !== 'announcements' && adminPubkeys.includes(event.pubkey)) {
|
|
||||||
notificationManager.notifyForNote(event, nostrStore.account?.pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last seen timestamp
|
|
||||||
lastSeenTimestamp = Math.max(lastSeenTimestamp, event.created_at)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to start real-time subscription:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldIncludeNote = (note: NostrNote): boolean => {
|
|
||||||
if (config.feedType === 'announcements') {
|
|
||||||
return adminPubkeys.length > 0 && adminPubkeys.includes(note.pubkey)
|
|
||||||
}
|
|
||||||
if (config.feedType === 'general' && adminPubkeys.length > 0) {
|
|
||||||
return !adminPubkeys.includes(note.pubkey)
|
|
||||||
}
|
|
||||||
// For other feed types, include all notes
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectToFeed = async () => {
|
|
||||||
try {
|
|
||||||
console.log('Connecting to Nostr feed...')
|
|
||||||
await relayHub.connect()
|
|
||||||
isConnected.value = relayHub.isConnected.value
|
|
||||||
console.log('Connected to Nostr feed')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error connecting to feed:', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const disconnectFromFeed = () => {
|
|
||||||
if (unsubscribe) {
|
|
||||||
unsubscribe()
|
|
||||||
unsubscribe = null
|
|
||||||
}
|
|
||||||
isConnected.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetFeedState = () => {
|
|
||||||
notes.value = []
|
|
||||||
error.value = null
|
|
||||||
isLoading.value = false
|
|
||||||
isConnected.value = false
|
|
||||||
lastSeenTimestamp = Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
disconnectFromFeed()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize by loading from storage immediately
|
|
||||||
loadFromStorage()
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
notes: readonly(notes),
|
|
||||||
isLoading: readonly(isLoading),
|
|
||||||
error: readonly(error),
|
|
||||||
isConnected: readonly(isConnected),
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
loadNotes,
|
|
||||||
connectToFeed,
|
|
||||||
disconnectFromFeed,
|
|
||||||
subscribeToFeedUpdates,
|
|
||||||
resetFeedState,
|
|
||||||
cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useNostrFeed, type NostrFeedConfig } from './useNostrFeed'
|
|
||||||
|
|
||||||
export function useNostrFeedPreloader(config: NostrFeedConfig = {}) {
|
|
||||||
const feed = useNostrFeed(config)
|
|
||||||
|
|
||||||
// Preload state
|
|
||||||
const isPreloading = ref(false)
|
|
||||||
const isPreloaded = ref(false)
|
|
||||||
const preloadError = ref<Error | null>(null)
|
|
||||||
|
|
||||||
// Check if feed data is available in localStorage
|
|
||||||
const hasStoredData = computed(() => {
|
|
||||||
const storageKey = `nostr-feed-${config.feedType || 'all'}`
|
|
||||||
const storedData = localStorage.getItem(storageKey)
|
|
||||||
return storedData !== null
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check if we should show loading (only if no cached data and actually loading)
|
|
||||||
const shouldShowLoading = computed(() => {
|
|
||||||
return (feed.isLoading.value || isPreloading.value) && feed.notes.value.length === 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// Preload feed data
|
|
||||||
const preloadFeed = async () => {
|
|
||||||
if (isPreloaded.value || isPreloading.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isPreloading.value = true
|
|
||||||
preloadError.value = null
|
|
||||||
|
|
||||||
console.log('Preloading Nostr feed...')
|
|
||||||
|
|
||||||
// Connect and load notes
|
|
||||||
await feed.connectToFeed()
|
|
||||||
await feed.loadNotes()
|
|
||||||
|
|
||||||
// Subscribe to updates
|
|
||||||
feed.subscribeToFeedUpdates()
|
|
||||||
|
|
||||||
isPreloaded.value = true
|
|
||||||
console.log('Nostr feed preloaded successfully')
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to preload Nostr feed:', error)
|
|
||||||
preloadError.value = error instanceof Error ? error : new Error('Failed to preload feed')
|
|
||||||
} finally {
|
|
||||||
isPreloading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset preload state
|
|
||||||
const resetPreload = () => {
|
|
||||||
isPreloaded.value = false
|
|
||||||
isPreloading.value = false
|
|
||||||
preloadError.value = null
|
|
||||||
feed.resetFeedState()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-preload if we have stored data
|
|
||||||
const autoPreload = async () => {
|
|
||||||
if (hasStoredData.value && !isPreloaded.value && !isPreloading.value) {
|
|
||||||
await preloadFeed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
isPreloading: computed(() => isPreloading.value),
|
|
||||||
isPreloaded: computed(() => isPreloaded.value),
|
|
||||||
preloadError: computed(() => preloadError.value),
|
|
||||||
hasStoredData,
|
|
||||||
shouldShowLoading,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
preloadFeed,
|
|
||||||
resetPreload,
|
|
||||||
autoPreload,
|
|
||||||
|
|
||||||
// Expose feed methods
|
|
||||||
...feed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
import { ref } from 'vue'
|
|
||||||
import type { NostrNote } from '@/lib/nostr/client'
|
|
||||||
import { createTextNote, createReaction, createProfileMetadata } from '@/lib/nostr/events'
|
|
||||||
import { identity } from '@/composables/useIdentity'
|
|
||||||
import { toast } from 'vue-sonner'
|
|
||||||
import { useRelayHub } from './useRelayHub'
|
|
||||||
|
|
||||||
export function useSocial() {
|
|
||||||
const relayHub = useRelayHub()
|
|
||||||
const isPublishing = ref(false)
|
|
||||||
const profiles = ref(new Map<string, any>())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a text note
|
|
||||||
*/
|
|
||||||
async function publishNote(content: string, replyTo?: string): Promise<void> {
|
|
||||||
if (!identity.isAuthenticated.value || !identity.currentIdentity.value) {
|
|
||||||
throw new Error('Must be logged in to publish notes')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isPublishing.value = true
|
|
||||||
|
|
||||||
await relayHub.connect()
|
|
||||||
const event = createTextNote(content, identity.currentIdentity.value, replyTo)
|
|
||||||
await relayHub.publishEvent(event)
|
|
||||||
|
|
||||||
toast.success(replyTo ? 'Reply published!' : 'Note published!')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to publish note'
|
|
||||||
toast.error(message)
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
isPublishing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a reaction to a note
|
|
||||||
*/
|
|
||||||
async function publishReaction(targetEventId: string, targetAuthor: string, reaction: string = '👍'): Promise<void> {
|
|
||||||
if (!identity.isAuthenticated.value || !identity.currentIdentity.value) {
|
|
||||||
throw new Error('Must be logged in to react to notes')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await relayHub.connect()
|
|
||||||
const event = createReaction(targetEventId, targetAuthor, reaction, identity.currentIdentity.value)
|
|
||||||
await relayHub.publishEvent(event)
|
|
||||||
|
|
||||||
toast.success('Reaction added!')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to add reaction'
|
|
||||||
toast.error(message)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish profile metadata
|
|
||||||
*/
|
|
||||||
async function publishProfile(profileData: any): Promise<void> {
|
|
||||||
if (!identity.isAuthenticated.value || !identity.currentIdentity.value) {
|
|
||||||
throw new Error('Must be logged in to update profile')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isPublishing.value = true
|
|
||||||
|
|
||||||
await relayHub.connect()
|
|
||||||
const event = createProfileMetadata(profileData, identity.currentIdentity.value)
|
|
||||||
await relayHub.publishEvent(event)
|
|
||||||
|
|
||||||
toast.success('Profile updated on Nostr!')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to update profile'
|
|
||||||
toast.error(message)
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
isPublishing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch replies to a note
|
|
||||||
*/
|
|
||||||
async function fetchReplies(noteId: string): Promise<NostrNote[]> {
|
|
||||||
try {
|
|
||||||
await relayHub.connect()
|
|
||||||
return await relayHub.queryEvents([
|
|
||||||
{
|
|
||||||
kinds: [1], // TEXT_NOTE
|
|
||||||
'#e': [noteId] // Reply to specific event
|
|
||||||
}
|
|
||||||
])
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch replies:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch and cache user profiles
|
|
||||||
*/
|
|
||||||
async function fetchProfiles(pubkeys: string[]): Promise<void> {
|
|
||||||
// Filter out already cached profiles
|
|
||||||
const uncachedPubkeys = pubkeys.filter(pubkey => !profiles.value.has(pubkey))
|
|
||||||
|
|
||||||
if (uncachedPubkeys.length === 0) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await relayHub.connect()
|
|
||||||
const fetchedProfiles = await relayHub.queryEvents([
|
|
||||||
{
|
|
||||||
kinds: [0], // PROFILE_METADATA
|
|
||||||
authors: uncachedPubkeys
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// Update cache - convert events to profile map
|
|
||||||
fetchedProfiles.forEach((event) => {
|
|
||||||
try {
|
|
||||||
const profileData = JSON.parse(event.content)
|
|
||||||
profiles.value.set(event.pubkey, profileData)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to parse profile data for', event.pubkey)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch profiles:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached profile or return default
|
|
||||||
*/
|
|
||||||
function getProfile(pubkey: string) {
|
|
||||||
return profiles.value.get(pubkey) || {
|
|
||||||
name: pubkey.slice(0, 8) + '...',
|
|
||||||
display_name: undefined,
|
|
||||||
about: undefined,
|
|
||||||
picture: undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get display name for a pubkey
|
|
||||||
*/
|
|
||||||
function getDisplayName(pubkey: string): string {
|
|
||||||
const profile = getProfile(pubkey)
|
|
||||||
return profile.display_name || profile.name || pubkey.slice(0, 8) + '...'
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
isPublishing,
|
|
||||||
profiles,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
publishNote,
|
|
||||||
publishReaction,
|
|
||||||
publishProfile,
|
|
||||||
fetchReplies,
|
|
||||||
fetchProfiles,
|
|
||||||
getProfile,
|
|
||||||
getDisplayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance for global use
|
|
||||||
export const social = useSocial()
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
import { type Filter, type Event } from 'nostr-tools'
|
|
||||||
import { getReplyInfo, EventKinds } from './events'
|
|
||||||
import { relayHub } from './relayHub'
|
|
||||||
|
|
||||||
export interface NostrClientConfig {
|
|
||||||
relays: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NostrNote extends Event {
|
|
||||||
// Add any additional note-specific fields we want to track
|
|
||||||
replyCount: number
|
|
||||||
reactionCount: number
|
|
||||||
reactions: { [reaction: string]: number }
|
|
||||||
isReply: boolean
|
|
||||||
replyTo?: string
|
|
||||||
mentions: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NostrClient {
|
|
||||||
private relays: string[]
|
|
||||||
// private _isConnected: boolean = false
|
|
||||||
|
|
||||||
constructor(config: NostrClientConfig) {
|
|
||||||
this.relays = config.relays
|
|
||||||
}
|
|
||||||
|
|
||||||
get isConnected(): boolean {
|
|
||||||
return relayHub.isConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// The relay hub should already be initialized by the time this is called
|
|
||||||
if (!relayHub.isInitialized) {
|
|
||||||
throw new Error('RelayHub not initialized. Please ensure the app has initialized the relay hub first.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're already connected
|
|
||||||
if (relayHub.isConnected) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to connect using the relay hub
|
|
||||||
await relayHub.connect()
|
|
||||||
} catch (error) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
|
||||||
// Note: We don't disconnect the relay hub here as other components might be using it
|
|
||||||
// The relay hub will be managed at the app level
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchNotes(options: {
|
|
||||||
limit?: number
|
|
||||||
since?: number // Unix timestamp in seconds
|
|
||||||
authors?: string[]
|
|
||||||
includeReplies?: boolean
|
|
||||||
} = {}): Promise<NostrNote[]> {
|
|
||||||
const {
|
|
||||||
limit = 20,
|
|
||||||
authors,
|
|
||||||
includeReplies = false
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const filters: Filter[] = [
|
|
||||||
{
|
|
||||||
kinds: [EventKinds.TEXT_NOTE],
|
|
||||||
limit,
|
|
||||||
...(authors && { authors })
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the relay hub to query events
|
|
||||||
const allEvents = await relayHub.queryEvents(filters, this.relays)
|
|
||||||
|
|
||||||
const noteEvents = [allEvents] // Wrap in array to match expected format
|
|
||||||
|
|
||||||
// Flatten and deduplicate events by ID
|
|
||||||
const uniqueNotes = Array.from(
|
|
||||||
new Map(
|
|
||||||
noteEvents.flat().map(event => [event.id, event])
|
|
||||||
).values()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Process notes with basic info (engagement data disabled for now)
|
|
||||||
let processedNotes = uniqueNotes
|
|
||||||
.map((event: Event): NostrNote => {
|
|
||||||
const replyInfo = getReplyInfo(event)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...event,
|
|
||||||
replyCount: 0,
|
|
||||||
reactionCount: 0,
|
|
||||||
reactions: {},
|
|
||||||
isReply: replyInfo.isReply,
|
|
||||||
replyTo: replyInfo.replyTo,
|
|
||||||
mentions: replyInfo.mentions
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Filter out replies if not requested
|
|
||||||
if (!includeReplies) {
|
|
||||||
processedNotes = processedNotes.filter(note => !note.isReply)
|
|
||||||
}
|
|
||||||
|
|
||||||
return processedNotes
|
|
||||||
.sort((a: NostrNote, b: NostrNote) => b.created_at - a.created_at) // Sort by newest first
|
|
||||||
.slice(0, limit) // Apply limit after processing
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch notes:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish an event to all connected relays
|
|
||||||
*/
|
|
||||||
async publishEvent(event: Event): Promise<void> {
|
|
||||||
if (!relayHub.isConnected) {
|
|
||||||
throw new Error('Not connected to any relays')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await relayHub.publishEvent(event)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to publish event:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch replies to a specific note
|
|
||||||
*/
|
|
||||||
async fetchReplies(noteId: string, limit: number = 50): Promise<NostrNote[]> {
|
|
||||||
const filter: Filter = {
|
|
||||||
kinds: [EventKinds.TEXT_NOTE],
|
|
||||||
'#e': [noteId],
|
|
||||||
limit
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await relayHub.queryEvents([filter], this.relays)
|
|
||||||
|
|
||||||
// Flatten and deduplicate events by ID
|
|
||||||
const uniqueEvents = Array.from(
|
|
||||||
new Map(
|
|
||||||
events.map(event => [event.id, event])
|
|
||||||
).values()
|
|
||||||
)
|
|
||||||
|
|
||||||
return uniqueEvents
|
|
||||||
.sort((a: Event, b: Event) => a.created_at - b.created_at) // Chronological order for replies
|
|
||||||
.map((event: Event): NostrNote => {
|
|
||||||
const replyInfo = getReplyInfo(event)
|
|
||||||
return {
|
|
||||||
...event,
|
|
||||||
replyCount: 0, // Individual replies don't need reply counts in this context
|
|
||||||
reactionCount: 0, // We could add this later
|
|
||||||
reactions: {},
|
|
||||||
isReply: replyInfo.isReply,
|
|
||||||
replyTo: replyInfo.replyTo,
|
|
||||||
mentions: replyInfo.mentions
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch replies:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch events by kind
|
|
||||||
*/
|
|
||||||
async fetchEvents(options: {
|
|
||||||
kinds: number[]
|
|
||||||
authors?: string[]
|
|
||||||
limit?: number
|
|
||||||
since?: number
|
|
||||||
until?: number
|
|
||||||
'#d'?: string[]
|
|
||||||
}): Promise<Event[]> {
|
|
||||||
const {
|
|
||||||
kinds,
|
|
||||||
authors,
|
|
||||||
limit = 100,
|
|
||||||
since,
|
|
||||||
until,
|
|
||||||
'#d': dTags
|
|
||||||
} = options
|
|
||||||
|
|
||||||
// Build filter object, only including defined properties
|
|
||||||
const filter: Filter = {
|
|
||||||
kinds,
|
|
||||||
limit
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authors && authors.length > 0) {
|
|
||||||
filter.authors = authors
|
|
||||||
}
|
|
||||||
|
|
||||||
if (since) {
|
|
||||||
filter.since = since
|
|
||||||
}
|
|
||||||
|
|
||||||
if (until) {
|
|
||||||
filter.until = until
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dTags && dTags.length > 0) {
|
|
||||||
filter['#d'] = dTags
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters: Filter[] = [filter]
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Fetching events with filters:', JSON.stringify(filters, null, 2))
|
|
||||||
|
|
||||||
const events = await relayHub.queryEvents(filters, this.relays)
|
|
||||||
|
|
||||||
// Deduplicate events by ID
|
|
||||||
const uniqueEvents = Array.from(
|
|
||||||
new Map(events.map(event => [event.id, event])).values()
|
|
||||||
)
|
|
||||||
|
|
||||||
return uniqueEvents
|
|
||||||
.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
.slice(0, limit)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch events:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch user profiles
|
|
||||||
*/
|
|
||||||
async fetchProfiles(pubkeys: string[]): Promise<Map<string, any>> {
|
|
||||||
if (pubkeys.length === 0) return new Map()
|
|
||||||
|
|
||||||
const filter: Filter = {
|
|
||||||
kinds: [EventKinds.PROFILE_METADATA],
|
|
||||||
authors: pubkeys
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await relayHub.queryEvents([filter], this.relays)
|
|
||||||
|
|
||||||
const profiles = new Map<string, any>()
|
|
||||||
|
|
||||||
// Get the latest profile for each pubkey
|
|
||||||
pubkeys.forEach(pubkey => {
|
|
||||||
const userEvents = events
|
|
||||||
.filter(event => event.pubkey === pubkey)
|
|
||||||
.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
|
|
||||||
if (userEvents.length > 0) {
|
|
||||||
try {
|
|
||||||
const profile = JSON.parse(userEvents[0].content)
|
|
||||||
profiles.set(pubkey, profile)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to parse profile for ${pubkey}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return profiles
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch profiles:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to new notes in real-time
|
|
||||||
subscribeToNotes(onNote: (note: NostrNote) => void): () => void {
|
|
||||||
const filters = [{
|
|
||||||
kinds: [EventKinds.TEXT_NOTE],
|
|
||||||
since: Math.floor(Date.now() / 1000)
|
|
||||||
}]
|
|
||||||
|
|
||||||
// Use the relay hub to subscribe
|
|
||||||
const unsubscribe = relayHub.subscribe({
|
|
||||||
id: `notes-subscription-${Date.now()}`,
|
|
||||||
filters,
|
|
||||||
relays: this.relays,
|
|
||||||
onEvent: (event: Event) => {
|
|
||||||
const replyInfo = getReplyInfo(event)
|
|
||||||
onNote({
|
|
||||||
...event,
|
|
||||||
replyCount: 0,
|
|
||||||
reactionCount: 0,
|
|
||||||
reactions: {},
|
|
||||||
isReply: replyInfo.isReply,
|
|
||||||
replyTo: replyInfo.replyTo,
|
|
||||||
mentions: replyInfo.mentions
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return unsubscribe
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import { finalizeEvent, type EventTemplate, type Event } from 'nostr-tools'
|
|
||||||
import type { NostrIdentity } from './identity'
|
|
||||||
import { hexToBytes } from '@/lib/utils/crypto'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nostr event kinds
|
|
||||||
*/
|
|
||||||
export const EventKinds = {
|
|
||||||
TEXT_NOTE: 1,
|
|
||||||
REACTION: 7,
|
|
||||||
ZAP: 9735,
|
|
||||||
PROFILE_METADATA: 0,
|
|
||||||
CONTACT_LIST: 3,
|
|
||||||
REPOST: 6,
|
|
||||||
DELETE: 5
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a text note event
|
|
||||||
*/
|
|
||||||
export function createTextNote(content: string, identity: NostrIdentity, replyTo?: string): Event {
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: EventKinds.TEXT_NOTE,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [],
|
|
||||||
content
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add reply tags if this is a reply
|
|
||||||
if (replyTo) {
|
|
||||||
eventTemplate.tags.push(['e', replyTo, '', 'reply'])
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a reaction event (like/dislike)
|
|
||||||
*/
|
|
||||||
export function createReaction(
|
|
||||||
targetEventId: string,
|
|
||||||
targetAuthor: string,
|
|
||||||
reaction: string,
|
|
||||||
identity: NostrIdentity
|
|
||||||
): Event {
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: EventKinds.REACTION,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [
|
|
||||||
['e', targetEventId],
|
|
||||||
['p', targetAuthor]
|
|
||||||
],
|
|
||||||
content: reaction
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a profile metadata event
|
|
||||||
*/
|
|
||||||
export function createProfileMetadata(profile: Record<string, any>, identity: NostrIdentity): Event {
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: EventKinds.PROFILE_METADATA,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [],
|
|
||||||
content: JSON.stringify(profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a contact list event (following)
|
|
||||||
*/
|
|
||||||
export function createContactList(contacts: string[], identity: NostrIdentity): Event {
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: EventKinds.CONTACT_LIST,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: contacts.map(pubkey => ['p', pubkey]),
|
|
||||||
content: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a delete event
|
|
||||||
*/
|
|
||||||
export function createDeleteEvent(eventIds: string[], identity: NostrIdentity, reason?: string): Event {
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: EventKinds.DELETE,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: eventIds.map(id => ['e', id]),
|
|
||||||
content: reason || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract reply information from event tags
|
|
||||||
*/
|
|
||||||
export function getReplyInfo(event: any): { isReply: boolean; replyTo?: string; mentions: string[] } {
|
|
||||||
const eTags = event.tags.filter((tag: string[]) => tag[0] === 'e')
|
|
||||||
const pTags = event.tags.filter((tag: string[]) => tag[0] === 'p')
|
|
||||||
|
|
||||||
// Find reply target (last e tag with "reply" marker or last e tag)
|
|
||||||
const replyTag = eTags.find((tag: string[]) => tag[3] === 'reply') || eTags[eTags.length - 1]
|
|
||||||
|
|
||||||
return {
|
|
||||||
isReply: !!replyTag,
|
|
||||||
replyTo: replyTag?.[1],
|
|
||||||
mentions: pTags.map((tag: string[]) => tag[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract reactions from events
|
|
||||||
*/
|
|
||||||
export function extractReactions(events: any[]): Map<string, { [reaction: string]: number }> {
|
|
||||||
const reactions = new Map<string, { [reaction: string]: number }>()
|
|
||||||
|
|
||||||
events
|
|
||||||
.filter(event => event.kind === EventKinds.REACTION)
|
|
||||||
.forEach(event => {
|
|
||||||
const targetEventId = event.tags.find((tag: string[]) => tag[0] === 'e')?.[1]
|
|
||||||
if (!targetEventId) return
|
|
||||||
|
|
||||||
const reaction = event.content || '👍'
|
|
||||||
const current = reactions.get(targetEventId) || {}
|
|
||||||
current[reaction] = (current[reaction] || 0) + 1
|
|
||||||
reactions.set(targetEventId, current)
|
|
||||||
})
|
|
||||||
|
|
||||||
return reactions
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract reply counts from events
|
|
||||||
*/
|
|
||||||
export function extractReplyCounts(events: any[]): Map<string, number> {
|
|
||||||
const replyCounts = new Map<string, number>()
|
|
||||||
|
|
||||||
events
|
|
||||||
.filter(event => event.kind === EventKinds.TEXT_NOTE)
|
|
||||||
.forEach(event => {
|
|
||||||
const replyInfo = getReplyInfo(event)
|
|
||||||
if (replyInfo.isReply && replyInfo.replyTo) {
|
|
||||||
const current = replyCounts.get(replyInfo.replyTo) || 0
|
|
||||||
replyCounts.set(replyInfo.replyTo, current + 1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return replyCounts
|
|
||||||
}
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
|
|
||||||
import { SecureStorage } from '@/lib/crypto/encryption'
|
|
||||||
import { bytesToHex, hexToBytes } from '@/lib/utils/crypto'
|
|
||||||
|
|
||||||
export interface NostrIdentity {
|
|
||||||
privateKey: string
|
|
||||||
publicKey: string
|
|
||||||
npub: string
|
|
||||||
nsec: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NostrProfile {
|
|
||||||
name?: string
|
|
||||||
display_name?: string
|
|
||||||
about?: string
|
|
||||||
picture?: string
|
|
||||||
banner?: string
|
|
||||||
website?: string
|
|
||||||
nip05?: string
|
|
||||||
lud16?: string // Lightning address
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IdentityManager {
|
|
||||||
private static readonly STORAGE_KEY = 'nostr_identity'
|
|
||||||
private static readonly PROFILE_KEY = 'nostr_profile'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a new Nostr identity
|
|
||||||
*/
|
|
||||||
static generateIdentity(): NostrIdentity {
|
|
||||||
const privateKey = generateSecretKey()
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
return {
|
|
||||||
privateKey: bytesToHex(privateKey),
|
|
||||||
publicKey,
|
|
||||||
npub: nip19.npubEncode(publicKey),
|
|
||||||
nsec: nip19.nsecEncode(privateKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import identity from private key (hex or nsec format)
|
|
||||||
*/
|
|
||||||
static importIdentity(privateKeyInput: string): NostrIdentity {
|
|
||||||
let privateKeyBytes: Uint8Array
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to decode as nsec first
|
|
||||||
if (privateKeyInput.startsWith('nsec')) {
|
|
||||||
const decoded = nip19.decode(privateKeyInput)
|
|
||||||
if (decoded.type === 'nsec') {
|
|
||||||
privateKeyBytes = decoded.data
|
|
||||||
} else {
|
|
||||||
throw new Error('Invalid nsec format')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Try as hex string
|
|
||||||
if (privateKeyInput.length !== 64) {
|
|
||||||
throw new Error('Private key must be 64 hex characters')
|
|
||||||
}
|
|
||||||
privateKeyBytes = hexToBytes(privateKeyInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (privateKeyBytes.length !== 32) {
|
|
||||||
throw new Error('Private key must be 32 bytes')
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicKey = getPublicKey(privateKeyBytes)
|
|
||||||
|
|
||||||
return {
|
|
||||||
privateKey: bytesToHex(privateKeyBytes),
|
|
||||||
publicKey,
|
|
||||||
npub: nip19.npubEncode(publicKey),
|
|
||||||
nsec: nip19.nsecEncode(privateKeyBytes)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to import identity: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save identity to localStorage (encrypted)
|
|
||||||
*/
|
|
||||||
static async saveIdentity(identity: NostrIdentity, password?: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
let dataToStore: any = identity
|
|
||||||
|
|
||||||
if (password) {
|
|
||||||
if (!SecureStorage.isSupported()) {
|
|
||||||
throw new Error('Secure encryption is not supported in this browser')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt sensitive data using Web Crypto API
|
|
||||||
const sensitiveData = JSON.stringify({
|
|
||||||
privateKey: identity.privateKey,
|
|
||||||
nsec: identity.nsec
|
|
||||||
})
|
|
||||||
|
|
||||||
const encryptedData = await SecureStorage.encrypt(sensitiveData, password)
|
|
||||||
|
|
||||||
dataToStore = {
|
|
||||||
publicKey: identity.publicKey,
|
|
||||||
npub: identity.npub,
|
|
||||||
encrypted: true,
|
|
||||||
encryptedData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(dataToStore))
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to save identity: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load identity from localStorage
|
|
||||||
*/
|
|
||||||
static async loadIdentity(password?: string): Promise<NostrIdentity | null> {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(this.STORAGE_KEY)
|
|
||||||
if (!stored) return null
|
|
||||||
|
|
||||||
const storedData = JSON.parse(stored)
|
|
||||||
|
|
||||||
if (storedData.encrypted && password) {
|
|
||||||
if (!SecureStorage.isSupported()) {
|
|
||||||
throw new Error('Secure encryption is not supported in this browser')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt sensitive data
|
|
||||||
const decryptedSensitiveData = await SecureStorage.decrypt(storedData.encryptedData, password)
|
|
||||||
const { privateKey, nsec } = JSON.parse(decryptedSensitiveData)
|
|
||||||
|
|
||||||
return {
|
|
||||||
privateKey,
|
|
||||||
publicKey: storedData.publicKey,
|
|
||||||
npub: storedData.npub,
|
|
||||||
nsec
|
|
||||||
}
|
|
||||||
} else if (storedData.encrypted && !password) {
|
|
||||||
throw new Error('Password required to decrypt stored identity')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-encrypted identity (legacy support)
|
|
||||||
const identity = storedData as NostrIdentity
|
|
||||||
|
|
||||||
// Validate the loaded identity
|
|
||||||
if (!identity.privateKey || !identity.publicKey) {
|
|
||||||
throw new Error('Invalid stored identity')
|
|
||||||
}
|
|
||||||
|
|
||||||
return identity
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load identity:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear stored identity
|
|
||||||
*/
|
|
||||||
static clearIdentity(): void {
|
|
||||||
localStorage.removeItem(this.STORAGE_KEY)
|
|
||||||
localStorage.removeItem(this.PROFILE_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if identity exists in storage
|
|
||||||
*/
|
|
||||||
static hasStoredIdentity(): boolean {
|
|
||||||
return !!localStorage.getItem(this.STORAGE_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if stored identity is encrypted
|
|
||||||
*/
|
|
||||||
static isStoredIdentityEncrypted(): boolean {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(this.STORAGE_KEY)
|
|
||||||
if (!stored) return false
|
|
||||||
|
|
||||||
const storedData = JSON.parse(stored)
|
|
||||||
return !!storedData.encrypted
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save user profile
|
|
||||||
*/
|
|
||||||
static saveProfile(profile: NostrProfile): void {
|
|
||||||
localStorage.setItem(this.PROFILE_KEY, JSON.stringify(profile))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load user profile
|
|
||||||
*/
|
|
||||||
static loadProfile(): NostrProfile | null {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(this.PROFILE_KEY)
|
|
||||||
return stored ? JSON.parse(stored) : null
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load profile:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// Notification manager that integrates Nostr events with push notifications
|
// Notification manager for push notifications
|
||||||
import { pushService, type NotificationPayload } from './push'
|
import { pushService, type NotificationPayload } from './push'
|
||||||
import { configUtils } from '@/lib/config'
|
import { configUtils } from '@/lib/config'
|
||||||
import type { NostrNote } from '@/lib/nostr/client'
|
|
||||||
|
|
||||||
export interface NotificationOptions {
|
export interface NotificationOptions {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|
@ -64,168 +63,25 @@ export class NotificationManager {
|
||||||
this.saveOptions()
|
this.saveOptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if notifications should be sent for a note
|
// Send a test notification
|
||||||
shouldNotify(note: NostrNote, userPubkey?: string): boolean {
|
|
||||||
if (!this.options.enabled) return false
|
|
||||||
|
|
||||||
// Admin announcements
|
|
||||||
if (this.options.adminAnnouncements && configUtils.isAdminPubkey(note.pubkey)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mentions (if user is mentioned in the note)
|
|
||||||
if (this.options.mentions && userPubkey && note.mentions.includes(userPubkey)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replies (if it's a reply to user's note)
|
|
||||||
if (this.options.replies && userPubkey && note.isReply && note.replyTo) {
|
|
||||||
// We'd need to check if the reply is to the user's note
|
|
||||||
// This would require additional context about the user's notes
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create notification payload from Nostr note
|
|
||||||
private createNotificationPayload(note: NostrNote): NotificationPayload {
|
|
||||||
const isAdmin = configUtils.isAdminPubkey(note.pubkey)
|
|
||||||
|
|
||||||
let title = 'New Note'
|
|
||||||
let body = note.content
|
|
||||||
let tag = 'nostr-note'
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
title = '🚨 Admin Announcement'
|
|
||||||
tag = 'admin-announcement'
|
|
||||||
} else if (note.isReply) {
|
|
||||||
title = 'Reply'
|
|
||||||
tag = 'reply'
|
|
||||||
} else if (note.mentions.length > 0) {
|
|
||||||
title = 'Mention'
|
|
||||||
tag = 'mention'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate long content
|
|
||||||
if (body.length > 100) {
|
|
||||||
body = body.slice(0, 100) + '...'
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
icon: '/pwa-192x192.png',
|
|
||||||
badge: '/pwa-192x192.png',
|
|
||||||
tag,
|
|
||||||
requireInteraction: isAdmin, // Admin announcements require interaction
|
|
||||||
data: {
|
|
||||||
noteId: note.id,
|
|
||||||
pubkey: note.pubkey,
|
|
||||||
isAdmin,
|
|
||||||
url: '/',
|
|
||||||
timestamp: note.created_at
|
|
||||||
},
|
|
||||||
actions: isAdmin ? [
|
|
||||||
{
|
|
||||||
action: 'view',
|
|
||||||
title: 'View'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'dismiss',
|
|
||||||
title: 'Dismiss'
|
|
||||||
}
|
|
||||||
] : [
|
|
||||||
{
|
|
||||||
action: 'view',
|
|
||||||
title: 'View'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send notification for a Nostr note
|
|
||||||
async notifyForNote(note: NostrNote, userPubkey?: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (!this.shouldNotify(note, userPubkey)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pushService.isSupported()) {
|
|
||||||
console.warn('Push notifications not supported')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSubscribed = await pushService.isSubscribed()
|
|
||||||
if (!isSubscribed) {
|
|
||||||
console.log('User not subscribed to push notifications')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = this.createNotificationPayload(note)
|
|
||||||
await pushService.showLocalNotification(payload)
|
|
||||||
|
|
||||||
console.log('Notification sent for note:', note.id)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send notification for note:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send test notification
|
|
||||||
async sendTestNotification(): Promise<void> {
|
async sendTestNotification(): Promise<void> {
|
||||||
const testPayload: NotificationPayload = {
|
if (!this.options.enabled) {
|
||||||
title: '🚨 Test Admin Announcement',
|
throw new Error('Notifications are disabled')
|
||||||
body: 'This is a test notification to verify push notifications are working correctly.',
|
}
|
||||||
icon: '/pwa-192x192.png',
|
|
||||||
badge: '/pwa-192x192.png',
|
const payload: NotificationPayload = {
|
||||||
tag: 'test-notification',
|
title: '🧪 Test Notification',
|
||||||
requireInteraction: true,
|
body: 'This is a test notification from Ario',
|
||||||
|
tag: 'test',
|
||||||
|
icon: '/apple-touch-icon.png',
|
||||||
|
badge: '/apple-touch-icon.png',
|
||||||
data: {
|
data: {
|
||||||
url: '/',
|
url: window.location.origin,
|
||||||
type: 'test',
|
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
},
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
action: 'view',
|
|
||||||
title: 'View App'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'dismiss',
|
|
||||||
title: 'Dismiss'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
await pushService.showLocalNotification(testPayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle background notification processing
|
|
||||||
async processBackgroundNote(noteData: any): Promise<void> {
|
|
||||||
// This would be called from the service worker
|
|
||||||
// when receiving push notifications from a backend
|
|
||||||
try {
|
|
||||||
const payload = this.createNotificationPayload(noteData)
|
|
||||||
|
|
||||||
// Show notification via service worker
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
const registration = await navigator.serviceWorker.ready
|
|
||||||
await registration.showNotification(payload.title, payload)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to process background notification:', error)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user has denied notifications
|
await pushService.sendNotification(payload)
|
||||||
isBlocked(): boolean {
|
|
||||||
return pushService.getPermission() === 'denied'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if notifications are enabled and configured
|
|
||||||
isConfigured(): boolean {
|
|
||||||
return pushService.isSupported() && configUtils.hasPushConfig()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue