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:
padreug 2025-08-13 10:11:28 +02:00
parent 06bcc4b91e
commit b074cc4ca3
11 changed files with 299 additions and 2184 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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