feat: Add scripts for sending admin and test notes via Nostr

- Introduce `send_admin_note.js` for sending community announcements to Nostr relays.
- Implement `send_test_note.js` for testing note sending with specified private key and relay URL.
- Enhance `NostrFeed.vue` to filter notes based on admin pubkeys and display appropriate titles and descriptions for different feed types.
- Update `Home.vue` to use the announcements feed type for the Nostr feed component.
This commit is contained in:
padreug 2025-07-02 18:12:02 +02:00
parent ee7eb461c4
commit 97db2a2fec
5 changed files with 314 additions and 54 deletions

View file

@ -4,10 +4,13 @@ import { NostrClient, type NostrNote } from '@/lib/nostr/client'
import { useNostr } from '@/composables/useNostr'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { formatDistanceToNow } from 'date-fns'
import { Megaphone } from 'lucide-vue-next'
const props = defineProps<{
relays?: string[]
feedType?: 'all' | 'announcements' | 'events' | 'general'
}>()
const notes = ref<NostrNote[]>([])
@ -17,13 +20,50 @@ const error = ref<Error | null>(null)
const relayUrls = props.relays || JSON.parse(import.meta.env.VITE_NOSTR_RELAYS as string)
const { disconnect } = useNostr({ relays: relayUrls })
// Get admin/moderator pubkeys from environment
// These should be hex pubkeys of trusted moderators/admins
const adminPubkeys = import.meta.env.VITE_ADMIN_PUBKEYS
? JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS as string)
: []
async function loadNotes() {
try {
isLoading.value = true
error.value = null
const client = new NostrClient({ relays: relayUrls })
await client.connect()
notes.value = await client.fetchNotes({ limit: 50 })
// Configure fetch options based on feed type
const fetchOptions: Parameters<typeof client.fetchNotes>[0] = {
limit: 50,
includeReplies: false
}
// Filter by authors based on feed type
if (props.feedType === 'announcements') {
// Only show notes from admin/moderator pubkeys
if (adminPubkeys.length > 0) {
fetchOptions.authors = adminPubkeys
} else {
// If no admin pubkeys configured, show placeholder
notes.value = []
return
}
} else if (props.feedType === 'general') {
// Show notes from everyone EXCEPT admins (if configured)
// Note: This would require client-side filtering after fetch
// For now, we'll fetch all and filter
}
// 'all' and 'events' types get all notes (no author filter)
notes.value = await client.fetchNotes(fetchOptions)
// Client-side filtering for 'general' feed (exclude admin posts)
if (props.feedType === 'general' && adminPubkeys.length > 0) {
notes.value = notes.value.filter(note => !adminPubkeys.includes(note.pubkey))
}
} catch (err) {
error.value = err instanceof Error ? err : new Error('Failed to load notes')
} finally {
@ -31,6 +71,36 @@ async function loadNotes() {
}
}
function isAdminPost(pubkey: string): boolean {
return adminPubkeys.includes(pubkey)
}
function getFeedTitle(): string {
switch (props.feedType) {
case 'announcements':
return 'Community Announcements'
case 'events':
return 'Events & Calendar'
case 'general':
return 'General Discussion'
default:
return 'Community Feed'
}
}
function getFeedDescription(): string {
switch (props.feedType) {
case 'announcements':
return 'Important announcements from community administrators'
case 'events':
return 'Upcoming events and calendar updates'
case 'general':
return 'Community discussions and general posts'
default:
return 'Latest posts from the community'
}
}
onMounted(async () => {
await loadNotes()
})
@ -48,8 +118,11 @@ function formatDate(timestamp: number): string {
<div class="w-full max-w-3xl mx-auto p-4">
<Card class="w-full">
<CardHeader>
<CardTitle>Nostr Feed</CardTitle>
<CardDescription>Latest notes from the nostr network</CardDescription>
<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">
@ -62,17 +135,40 @@ function formatDate(timestamp: number): string {
</div>
<div v-else-if="notes.length === 0" class="text-center py-8 text-muted-foreground">
No notes found
<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 class="space-y-4">
<Card v-for="note in notes" :key="note.id" class="p-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">
<span class="font-medium text-sm">{{ note.pubkey.slice(0, 8) }}...</span>
<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">{{ note.content }}</p>
<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>
@ -90,6 +186,9 @@ function formatDate(timestamp: number): string {
>
Refresh
</button>
<span v-if="adminPubkeys.length > 0" class="text-xs text-muted-foreground">
{{ adminPubkeys.length }} admin(s) configured
</span>
</CardFooter>
</Card>
</div>

View file

@ -61,7 +61,7 @@ export class NostrClient {
} = {}): Promise<NostrNote[]> {
const {
limit = 20,
since = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000),
since = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000), // Last 7 days
authors,
includeReplies = false
} = options
@ -69,45 +69,25 @@ export class NostrClient {
const filters: Filter[] = [
{
kinds: [EventKinds.TEXT_NOTE],
since,
limit,
...(authors && { authors })
}
]
// Also fetch reactions and replies for engagement data
const engagementFilters: Filter[] = [
{
kinds: [EventKinds.REACTION, EventKinds.TEXT_NOTE],
since,
limit: limit * 5 // Get more for engagement calculation
}
]
try {
// Get events from all relays
const [noteEvents, engagementEvents] = await Promise.all([
Promise.all(
this.relays.map(async (relay) => {
try {
return await this.pool.querySync([relay], filters)
} catch (error) {
console.warn(`Failed to fetch notes from relay ${relay}:`, error)
return []
}
})
),
Promise.all(
this.relays.map(async (relay) => {
try {
return await this.pool.querySync([relay], engagementFilters)
} catch (error) {
console.warn(`Failed to fetch engagement from relay ${relay}:`, error)
return []
}
})
)
])
// Get events from all relays using the working get() method
const noteEvents = await Promise.all(
this.relays.map(async (relay) => {
try {
const filter = filters[0]
const singleEvent = await this.pool.get([relay], filter)
return singleEvent ? [singleEvent] : []
} catch (error) {
console.warn(`Failed to fetch notes from relay ${relay}:`, error)
return []
}
})
)
// Flatten and deduplicate events by ID
const uniqueNotes = Array.from(
@ -116,24 +96,16 @@ export class NostrClient {
).values()
)
const allEngagementEvents = engagementEvents.flat()
// Extract engagement data
const reactions = extractReactions(allEngagementEvents)
const replyCounts = extractReplyCounts(allEngagementEvents)
// Process notes with engagement data
// Process notes with basic info (engagement data disabled for now)
let processedNotes = uniqueNotes
.map((event: Event): NostrNote => {
const replyInfo = getReplyInfo(event)
const eventReactions = reactions.get(event.id) || {}
const reactionCount = Object.values(eventReactions).reduce((sum, count) => sum + count, 0)
return {
...event,
replyCount: replyCounts.get(event.id) || 0,
reactionCount,
reactions: eventReactions,
replyCount: 0,
reactionCount: 0,
reactions: {},
isReply: replyInfo.isReply,
replyTo: replyInfo.replyTo,
mentions: replyInfo.mentions

View file

@ -1,6 +1,6 @@
<template>
<div class="container py-8">
<NostrFeed />
<NostrFeed feed-type="announcements" />
</div>
</template>