Ensures profiles are fetched for authors and completers of scheduled events, improving user experience by displaying relevant user information. This is achieved by watching for scheduled events and completions, then fetching profiles for any new pubkeys encountered.
492 lines
16 KiB
Vue
492 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { computed, watch, ref } from 'vue'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
|
import { useFeed } from '../composables/useFeed'
|
|
import { useProfiles } from '../composables/useProfiles'
|
|
import { useReactions } from '../composables/useReactions'
|
|
import { useScheduledEvents } from '../composables/useScheduledEvents'
|
|
import ThreadedPost from './ThreadedPost.vue'
|
|
import ScheduledEventCard from './ScheduledEventCard.vue'
|
|
import appConfig from '@/app.config'
|
|
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
|
import type { ScheduledEvent } from '../services/ScheduledEventService'
|
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
import type { AuthService } from '@/modules/base/auth/auth-service'
|
|
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
|
import { finalizeEvent } from 'nostr-tools'
|
|
import { useToast } from '@/core/composables/useToast'
|
|
|
|
interface Emits {
|
|
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
|
|
}
|
|
|
|
const props = defineProps<{
|
|
relays?: string[]
|
|
feedType?: 'all' | 'announcements' | 'rideshare' | 'custom'
|
|
contentFilters?: ContentFilter[]
|
|
adminPubkeys?: string[]
|
|
compactMode?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
// Inject services
|
|
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
|
|
const relayHub = injectService<RelayHub>(SERVICE_TOKENS.RELAY_HUB)
|
|
const toast = useToast()
|
|
|
|
// Get admin/moderator pubkeys from props or app config
|
|
const adminPubkeys = props.adminPubkeys || appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
|
|
|
|
// Get current user's pubkey
|
|
const currentUserPubkey = computed(() => authService?.user.value?.pubkey || null)
|
|
|
|
// Use centralized feed service - this handles all subscription management and deduplication
|
|
const { posts: notes, threadedPosts, isLoading, error, refreshFeed } = useFeed({
|
|
feedType: props.feedType || 'all',
|
|
maxPosts: 100,
|
|
adminPubkeys,
|
|
contentFilters: props.contentFilters
|
|
})
|
|
|
|
// Centralized collapse state management
|
|
const collapsedPosts = ref(new Set<string>())
|
|
|
|
// Track which posts should show limited replies (not collapsed, just limited)
|
|
const limitedReplyPosts = ref(new Set<string>())
|
|
|
|
// Delete confirmation dialog state
|
|
const showDeleteDialog = ref(false)
|
|
const postToDelete = ref<FeedPost | null>(null)
|
|
|
|
// Initialize posts that should show limited replies (>2 children)
|
|
watch(threadedPosts, (newPosts) => {
|
|
if (newPosts.length > 0) {
|
|
const newLimited = new Set<string>()
|
|
|
|
// Find posts with more than 2 direct replies - these should show limited replies by default
|
|
const findLimitedPosts = (posts: any[]) => {
|
|
posts.forEach(post => {
|
|
if ((post.replies?.length || 0) > 2) {
|
|
// Mark this post as having limited replies shown
|
|
newLimited.add(post.id)
|
|
}
|
|
// Recursively check nested replies
|
|
if (post.replies?.length > 0) {
|
|
findLimitedPosts(post.replies)
|
|
}
|
|
})
|
|
}
|
|
|
|
findLimitedPosts(newPosts)
|
|
limitedReplyPosts.value = newLimited
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// Use profiles service for display names
|
|
const { getDisplayName, fetchProfiles } = useProfiles()
|
|
|
|
// Use reactions service for likes/hearts
|
|
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
|
|
|
// Use scheduled events service
|
|
const { getTodaysEvents, getCompletion, toggleComplete, allCompletions } = useScheduledEvents()
|
|
|
|
// Get today's scheduled events (reactive)
|
|
const todaysScheduledEvents = computed(() => getTodaysEvents())
|
|
|
|
// Watch for new posts and fetch their profiles and reactions
|
|
watch(notes, async (newNotes) => {
|
|
if (newNotes.length > 0) {
|
|
const pubkeys = [...new Set(newNotes.map(note => note.pubkey))]
|
|
const eventIds = newNotes.map(note => note.id)
|
|
|
|
// Fetch profiles and subscribe to reactions in parallel
|
|
await Promise.all([
|
|
fetchProfiles(pubkeys),
|
|
subscribeToReactions(eventIds)
|
|
])
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// Watch for scheduled events and fetch profiles for event authors and completers
|
|
watch(todaysScheduledEvents, async (events) => {
|
|
if (events.length > 0) {
|
|
const pubkeys = new Set<string>()
|
|
|
|
// Add event authors
|
|
events.forEach(event => {
|
|
pubkeys.add(event.pubkey)
|
|
|
|
// Add completer pubkey if event is completed
|
|
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
|
const completion = getCompletion(eventAddress)
|
|
if (completion) {
|
|
pubkeys.add(completion.pubkey)
|
|
}
|
|
})
|
|
|
|
// Fetch all profiles
|
|
if (pubkeys.size > 0) {
|
|
await fetchProfiles([...pubkeys])
|
|
}
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// Watch for new completions and fetch profiles for completers
|
|
watch(allCompletions, async (completions) => {
|
|
if (completions.size > 0) {
|
|
const pubkeys = [...completions.values()].map(c => c.pubkey)
|
|
if (pubkeys.length > 0) {
|
|
await fetchProfiles(pubkeys)
|
|
}
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// Check if we have admin pubkeys configured
|
|
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
|
|
|
|
// Get feed title and description based on type
|
|
const feedTitle = computed(() => {
|
|
switch (props.feedType) {
|
|
case 'announcements':
|
|
return 'Announcements'
|
|
case 'rideshare':
|
|
return 'Rideshare'
|
|
case 'all':
|
|
default:
|
|
return 'All Content'
|
|
}
|
|
})
|
|
|
|
const feedDescription = computed(() => {
|
|
switch (props.feedType) {
|
|
case 'announcements':
|
|
return 'Important announcements from community administrators'
|
|
case 'rideshare':
|
|
return 'Rideshare requests and offers'
|
|
case 'all':
|
|
default:
|
|
return 'All community posts and updates'
|
|
}
|
|
})
|
|
|
|
|
|
|
|
|
|
// Handle reply to note
|
|
function onReplyToNote(note: any) {
|
|
emit('reply-to-note', {
|
|
id: note.id,
|
|
content: note.content,
|
|
pubkey: note.pubkey
|
|
})
|
|
}
|
|
|
|
// Handle like/heart reaction toggle
|
|
async function onToggleLike(note: FeedPost) {
|
|
try {
|
|
await toggleLike(note.id, note.pubkey, note.kind)
|
|
} catch (error) {
|
|
console.error('Failed to toggle like:', error)
|
|
}
|
|
}
|
|
|
|
// Handle scheduled event completion toggle
|
|
async function onToggleComplete(event: ScheduledEvent) {
|
|
console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title)
|
|
try {
|
|
await toggleComplete(event)
|
|
console.log('✅ NostrFeed: toggleComplete succeeded')
|
|
} catch (error) {
|
|
console.error('❌ NostrFeed: Failed to toggle event completion:', error)
|
|
}
|
|
}
|
|
|
|
// Handle collapse toggle with cascading behavior
|
|
function onToggleCollapse(postId: string) {
|
|
const newCollapsed = new Set(collapsedPosts.value)
|
|
|
|
if (newCollapsed.has(postId)) {
|
|
// Expand this post (remove from collapsed set)
|
|
newCollapsed.delete(postId)
|
|
} else {
|
|
// Collapse this post (add to collapsed set)
|
|
newCollapsed.add(postId)
|
|
|
|
// Find all descendant posts and collapse them too (cascading collapse)
|
|
const collapseDescendants = (posts: any[], targetId: string) => {
|
|
posts.forEach(post => {
|
|
if (post.id === targetId && post.replies) {
|
|
// Found the target post, collapse all its descendants
|
|
const addAllDescendants = (replies: any[]) => {
|
|
replies.forEach(reply => {
|
|
newCollapsed.add(reply.id)
|
|
if (reply.replies?.length > 0) {
|
|
addAllDescendants(reply.replies)
|
|
}
|
|
})
|
|
}
|
|
addAllDescendants(post.replies)
|
|
} else if (post.replies?.length > 0) {
|
|
// Keep searching in nested replies
|
|
collapseDescendants(post.replies, targetId)
|
|
}
|
|
})
|
|
}
|
|
|
|
collapseDescendants(threadedPosts.value, postId)
|
|
}
|
|
|
|
collapsedPosts.value = newCollapsed
|
|
}
|
|
|
|
// Handle toggle limited replies (show/hide extra replies beyond first 2)
|
|
function onToggleLimited(postId: string) {
|
|
const newLimited = new Set(limitedReplyPosts.value)
|
|
|
|
if (newLimited.has(postId)) {
|
|
// Show all replies
|
|
newLimited.delete(postId)
|
|
} else {
|
|
// Limit to first 2 replies
|
|
newLimited.add(postId)
|
|
}
|
|
|
|
limitedReplyPosts.value = newLimited
|
|
}
|
|
|
|
// Helper function to convert hex string to Uint8Array
|
|
const hexToUint8Array = (hex: string): Uint8Array => {
|
|
const bytes = new Uint8Array(hex.length / 2)
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
// Handle delete post button click - show confirmation dialog
|
|
function onDeletePost(note: FeedPost) {
|
|
if (!authService?.isAuthenticated.value || !authService?.user.value) {
|
|
toast.error("Please sign in to delete posts")
|
|
return
|
|
}
|
|
|
|
// Verify user owns the post
|
|
if (note.pubkey !== currentUserPubkey.value) {
|
|
toast.error("You can only delete your own posts")
|
|
return
|
|
}
|
|
|
|
// Show confirmation dialog
|
|
postToDelete.value = note
|
|
showDeleteDialog.value = true
|
|
}
|
|
|
|
// Confirm and execute post deletion (NIP-09: Event Deletion Request)
|
|
async function confirmDeletePost() {
|
|
const note = postToDelete.value
|
|
if (!note) return
|
|
|
|
if (!relayHub?.isConnected.value) {
|
|
toast.error("Not connected to Nostr relays")
|
|
showDeleteDialog.value = false
|
|
postToDelete.value = null
|
|
return
|
|
}
|
|
|
|
const userPrivkey = authService?.user.value?.prvkey
|
|
if (!userPrivkey) {
|
|
toast.error("User private key not available")
|
|
showDeleteDialog.value = false
|
|
postToDelete.value = null
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Create deletion event (NIP-09)
|
|
const deletionEvent = {
|
|
kind: 5, // Event Deletion Request
|
|
content: 'Deleted by author',
|
|
tags: [
|
|
['e', note.id], // Reference to event being deleted
|
|
['k', String(note.kind)] // Kind of event being deleted
|
|
],
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
}
|
|
|
|
// Sign the deletion event
|
|
const privkeyBytes = hexToUint8Array(userPrivkey)
|
|
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
|
|
|
|
// Publish the deletion request
|
|
const result = await relayHub.publishEvent(signedEvent)
|
|
|
|
if (result.success > 0) {
|
|
toast.success(`Deletion request sent to ${result.success}/${result.total} relays`)
|
|
// The post will be removed automatically via websocket when relays broadcast the deletion event
|
|
} else {
|
|
toast.error("Failed to send deletion request to any relay")
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to delete post:', error)
|
|
toast.error(error instanceof Error ? error.message : "Failed to delete post")
|
|
} finally {
|
|
// Close dialog and clear state
|
|
showDeleteDialog.value = false
|
|
postToDelete.value = null
|
|
}
|
|
}
|
|
|
|
// Cancel delete action
|
|
function cancelDelete() {
|
|
showDeleteDialog.value = false
|
|
postToDelete.value = null
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col">
|
|
<!-- Compact Header (only in non-compact mode) -->
|
|
<div v-if="!compactMode" class="flex items-center justify-between p-4 md:p-6 border-b md:bg-card/50 md:backdrop-blur-sm">
|
|
<div class="w-full max-w-3xl mx-auto flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<Megaphone class="h-5 w-5 md:h-6 md:w-6 text-primary" />
|
|
<div>
|
|
<h2 class="text-lg md:text-xl font-bold">{{ feedTitle }}</h2>
|
|
<p class="text-xs md:text-sm text-muted-foreground">{{ feedDescription }}</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
@click="refreshFeed"
|
|
:disabled="isLoading"
|
|
class="gap-2 md:h-10 md:px-4 hover:bg-accent transition-colors"
|
|
>
|
|
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
|
|
<span class="hidden md:inline">Refresh</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Feed Content Container -->
|
|
<div class="w-full max-w-3xl mx-auto px-0 md:px-4">
|
|
<!-- Loading State -->
|
|
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
|
<div class="flex items-center gap-2">
|
|
<RefreshCw class="h-4 w-4 animate-spin" />
|
|
<span class="text-muted-foreground">Loading feed...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="error" class="text-center py-8 px-4">
|
|
<div class="flex items-center justify-center gap-2 text-destructive mb-4">
|
|
<AlertCircle class="h-5 w-5" />
|
|
<span>Failed to load feed</span>
|
|
</div>
|
|
<p class="text-sm text-muted-foreground mb-4">{{ error }}</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 px-4">
|
|
<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 Posts -->
|
|
<div v-else-if="threadedPosts.length === 0" class="text-center py-8 px-4">
|
|
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
|
|
<Megaphone class="h-5 w-5" />
|
|
<span>No posts yet</span>
|
|
</div>
|
|
<p class="text-sm text-muted-foreground">
|
|
Check back later for community updates.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Posts List - Natural flow without internal scrolling -->
|
|
<div v-else>
|
|
<!-- Today's Scheduled Events Section -->
|
|
<div v-if="todaysScheduledEvents.length > 0" class="mb-6 md:mb-8">
|
|
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 md:px-0 mb-3">
|
|
📅 Today's Events
|
|
</h3>
|
|
<div class="md:space-y-3">
|
|
<ScheduledEventCard
|
|
v-for="event in todaysScheduledEvents"
|
|
:key="`${event.pubkey}:${event.dTag}`"
|
|
:event="event"
|
|
:get-display-name="getDisplayName"
|
|
:get-completion="getCompletion"
|
|
:admin-pubkeys="adminPubkeys"
|
|
@toggle-complete="onToggleComplete"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Posts Section -->
|
|
<div v-if="threadedPosts.length > 0" class="md:space-y-4 md:py-4">
|
|
<h3 v-if="todaysScheduledEvents.length > 0" class="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 md:px-0 mb-3 mt-6">
|
|
💬 Posts
|
|
</h3>
|
|
<ThreadedPost
|
|
v-for="post in threadedPosts"
|
|
:key="post.id"
|
|
:post="post"
|
|
:admin-pubkeys="adminPubkeys"
|
|
:current-user-pubkey="currentUserPubkey"
|
|
:get-display-name="getDisplayName"
|
|
:get-event-reactions="getEventReactions"
|
|
:depth="0"
|
|
:parent-collapsed="false"
|
|
:collapsed-posts="collapsedPosts"
|
|
:limited-reply-posts="limitedReplyPosts"
|
|
@reply-to-note="onReplyToNote"
|
|
@toggle-like="onToggleLike"
|
|
@toggle-collapse="onToggleCollapse"
|
|
@toggle-limited="onToggleLimited"
|
|
@delete-post="onDeletePost"
|
|
/>
|
|
</div>
|
|
|
|
<!-- End of feed message -->
|
|
<div class="text-center py-6 text-md text-muted-foreground">
|
|
<p>🐢</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Dialog -->
|
|
<Dialog :open="showDeleteDialog" @update:open="(val: boolean) => showDeleteDialog = val">
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete Post?</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete this post? This action cannot be undone. The deletion request will be sent to all relays.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" @click="cancelDelete">Cancel</Button>
|
|
<Button variant="destructive" @click="confirmDeletePost">Delete</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</template>
|