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">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { Megaphone } from 'lucide-vue-next'
|
||||
import { config } from '@/lib/config'
|
||||
import { useNostrFeedPreloader } from '@/composables/useNostrFeedPreloader'
|
||||
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||
import { config, configUtils } from '@/lib/config'
|
||||
import { useRelayHub } from '@/composables/useRelayHub'
|
||||
|
||||
const props = defineProps<{
|
||||
relays?: string[]
|
||||
feedType?: 'all' | 'announcements' | 'events' | 'general'
|
||||
}>()
|
||||
|
||||
const feedPreloader = useNostrFeedPreloader({
|
||||
relays: props.relays,
|
||||
feedType: props.feedType,
|
||||
limit: 50,
|
||||
includeReplies: false
|
||||
})
|
||||
const relayHub = useRelayHub()
|
||||
|
||||
const {
|
||||
notes,
|
||||
isLoading,
|
||||
error,
|
||||
isPreloading,
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Reactive state
|
||||
const notes = ref<any[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
const isConnected = ref(false)
|
||||
|
||||
// Get admin/moderator pubkeys from centralized config
|
||||
const adminPubkeys = config.nostr.adminPubkeys
|
||||
|
||||
function isAdminPost(pubkey: string): boolean {
|
||||
return adminPubkeys.includes(pubkey)
|
||||
}
|
||||
// Check if we have admin pubkeys configured
|
||||
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
|
||||
|
||||
function getFeedTitle(): string {
|
||||
// Get feed title and description based on type
|
||||
const feedTitle = computed(() => {
|
||||
switch (props.feedType) {
|
||||
case 'announcements':
|
||||
return 'Community Announcements'
|
||||
|
|
@ -92,9 +40,9 @@ function getFeedTitle(): string {
|
|||
default:
|
||||
return 'Community Feed'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function getFeedDescription(): string {
|
||||
const feedDescription = computed(() => {
|
||||
switch (props.feedType) {
|
||||
case 'announcements':
|
||||
return 'Important announcements from community administrators'
|
||||
|
|
@ -105,108 +53,284 @@ function getFeedDescription(): string {
|
|||
default:
|
||||
return 'Latest posts from the community'
|
||||
}
|
||||
})
|
||||
|
||||
// Check if a post is from an admin
|
||||
function isAdminPost(pubkey: string): boolean {
|
||||
return configUtils.isAdminPubkey(pubkey)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Only load feed if it hasn't been preloaded
|
||||
if (needsToLoadFeed.value) {
|
||||
console.log('Feed not preloaded, loading now...')
|
||||
loadFeed()
|
||||
} 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')
|
||||
// Load notes from relays
|
||||
async function loadNotes() {
|
||||
if (!hasAdminPubkeys.value && props.feedType === 'announcements') {
|
||||
notes.value = []
|
||||
return
|
||||
}
|
||||
|
||||
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(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-3xl mx-auto">
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Megaphone v-if="feedType === 'announcements'" class="h-5 w-5" />
|
||||
{{ getFeedTitle() }}
|
||||
</CardTitle>
|
||||
<CardDescription>{{ getFeedDescription() }}</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>
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Megaphone class="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<CardTitle>{{ feedTitle }}</CardTitle>
|
||||
<CardDescription>{{ feedDescription }}</CardDescription>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error || preloadError" class="text-center py-8 text-destructive">
|
||||
{{ (error || preloadError)?.message }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="notes.length === 0 && !isLoading && !isPreloading" class="text-center py-8 text-muted-foreground">
|
||||
<div v-if="feedType === 'announcements' && adminPubkeys.length === 0" class="space-y-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"
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="refreshFeed"
|
||||
:disabled="isLoading"
|
||||
class="gap-2"
|
||||
>
|
||||
<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
|
||||
</button>
|
||||
<span v-if="adminPubkeys.length > 0" class="text-xs text-muted-foreground">
|
||||
{{ adminPubkeys.length }} admin(s) configured
|
||||
</span>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<!-- Connection Status -->
|
||||
<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>
|
||||
|
|
@ -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 { configUtils } from '@/lib/config'
|
||||
import type { NostrNote } from '@/lib/nostr/client'
|
||||
|
||||
export interface NotificationOptions {
|
||||
enabled: boolean
|
||||
|
|
@ -64,168 +63,25 @@ export class NotificationManager {
|
|||
this.saveOptions()
|
||||
}
|
||||
|
||||
// Check if notifications should be sent for a note
|
||||
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
|
||||
// Send a test notification
|
||||
async sendTestNotification(): Promise<void> {
|
||||
const testPayload: NotificationPayload = {
|
||||
title: '🚨 Test Admin Announcement',
|
||||
body: 'This is a test notification to verify push notifications are working correctly.',
|
||||
icon: '/pwa-192x192.png',
|
||||
badge: '/pwa-192x192.png',
|
||||
tag: 'test-notification',
|
||||
requireInteraction: true,
|
||||
if (!this.options.enabled) {
|
||||
throw new Error('Notifications are disabled')
|
||||
}
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
title: '🧪 Test Notification',
|
||||
body: 'This is a test notification from Ario',
|
||||
tag: 'test',
|
||||
icon: '/apple-touch-icon.png',
|
||||
badge: '/apple-touch-icon.png',
|
||||
data: {
|
||||
url: '/',
|
||||
type: 'test',
|
||||
url: window.location.origin,
|
||||
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
|
||||
isBlocked(): boolean {
|
||||
return pushService.getPermission() === 'denied'
|
||||
}
|
||||
|
||||
// Check if notifications are enabled and configured
|
||||
isConfigured(): boolean {
|
||||
return pushService.isSupported() && configUtils.hasPushConfig()
|
||||
await pushService.sendNotification(payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue