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:
parent
ee7eb461c4
commit
97db2a2fec
5 changed files with 314 additions and 54 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="container py-8">
|
||||
<NostrFeed />
|
||||
<NostrFeed feed-type="announcements" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue