- Introduced a centralized mechanism for managing the collapse state of posts, allowing for automatic collapsing of posts with more than 2 direct replies. - Enhanced the ThreadedPost component to utilize the centralized collapse state, improving the visibility and interaction of nested replies. - Added cascading collapse functionality to ensure that all descendant posts are collapsed when a parent post is collapsed. These changes contribute to a more organized and user-friendly experience within the NostrFeed module.
258 lines
8.2 KiB
Vue
258 lines
8.2 KiB
Vue
<script setup lang="ts">
|
|
import { computed, watch, ref } from 'vue'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
|
import { useFeed } from '../composables/useFeed'
|
|
import { useProfiles } from '../composables/useProfiles'
|
|
import { useReactions } from '../composables/useReactions'
|
|
import ThreadedPost from './ThreadedPost.vue'
|
|
import appConfig from '@/app.config'
|
|
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
|
|
|
interface Emits {
|
|
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
|
|
}
|
|
|
|
const props = defineProps<{
|
|
relays?: string[]
|
|
feedType?: 'all' | 'announcements' | 'events' | 'general' | 'custom'
|
|
contentFilters?: ContentFilter[]
|
|
adminPubkeys?: string[]
|
|
compactMode?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
// Get admin/moderator pubkeys from props or app config
|
|
const adminPubkeys = props.adminPubkeys || appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
|
|
|
|
// 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>())
|
|
|
|
// Initialize collapsed state for posts with many replies
|
|
watch(threadedPosts, (newPosts) => {
|
|
if (newPosts.length > 0) {
|
|
const newCollapsed = new Set(collapsedPosts.value)
|
|
|
|
// Auto-collapse posts with more than 2 direct replies
|
|
const addCollapsedPosts = (posts: any[]) => {
|
|
posts.forEach(post => {
|
|
if ((post.replies?.length || 0) > 2) {
|
|
newCollapsed.add(post.id)
|
|
}
|
|
// Recursively check nested replies
|
|
if (post.replies?.length > 0) {
|
|
addCollapsedPosts(post.replies)
|
|
}
|
|
})
|
|
}
|
|
|
|
addCollapsedPosts(newPosts)
|
|
collapsedPosts.value = newCollapsed
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// Use profiles service for display names
|
|
const { getDisplayName, fetchProfiles } = useProfiles()
|
|
|
|
// Use reactions service for likes/hearts
|
|
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
|
|
|
// 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 })
|
|
|
|
// 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 'Community Announcements'
|
|
case 'events':
|
|
return 'Events & Calendar'
|
|
case 'general':
|
|
return 'General Discussion'
|
|
default:
|
|
return 'Community Feed'
|
|
}
|
|
})
|
|
|
|
const feedDescription = computed(() => {
|
|
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'
|
|
}
|
|
})
|
|
|
|
|
|
|
|
// 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 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
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col h-full">
|
|
<!-- Compact Header (only in non-compact mode) -->
|
|
<div v-if="!compactMode" class="flex items-center justify-between p-4 border-b">
|
|
<div class="flex items-center gap-2">
|
|
<Megaphone class="h-5 w-5 text-primary" />
|
|
<div>
|
|
<h2 class="text-lg font-semibold">{{ feedTitle }}</h2>
|
|
<p class="text-sm text-muted-foreground">{{ feedDescription }}</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
@click="refreshFeed"
|
|
:disabled="isLoading"
|
|
class="gap-2"
|
|
>
|
|
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Feed Content Container -->
|
|
<div class="flex-1 overflow-hidden">
|
|
<!-- 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 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 - Full height scroll with threaded view -->
|
|
<div v-else class="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
|
<div>
|
|
<ThreadedPost
|
|
v-for="post in threadedPosts"
|
|
:key="post.id"
|
|
:post="post"
|
|
:admin-pubkeys="adminPubkeys"
|
|
:get-display-name="getDisplayName"
|
|
:get-event-reactions="getEventReactions"
|
|
:depth="0"
|
|
:parent-collapsed="false"
|
|
:collapsed-posts="collapsedPosts"
|
|
@reply-to-note="onReplyToNote"
|
|
@toggle-like="onToggleLike"
|
|
@toggle-collapse="onToggleCollapse"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|