Implement limited replies feature in NostrFeed component

- Introduced a mechanism to track posts that should display limited replies (only the first 2) instead of collapsing them entirely, enhancing user interaction with threaded discussions.
- Updated the ThreadedPost component to support toggling between limited and full replies, improving visibility and engagement with nested replies.
- Enhanced the FeedService to ensure proper filtering and handling of feed types, contributing to a more organized and user-friendly experience within the NostrFeed module.
This commit is contained in:
padreug 2025-09-20 16:31:13 +02:00
parent 872954d5ce
commit f0a6b2bd1d
4 changed files with 85 additions and 40 deletions

View file

@ -37,26 +37,30 @@ const { posts: notes, threadedPosts, isLoading, error, refreshFeed } = useFeed({
// Centralized collapse state management
const collapsedPosts = ref(new Set<string>())
// Initialize collapsed state for posts with many replies
// Track which posts should show limited replies (not collapsed, just limited)
const limitedReplyPosts = ref(new Set<string>())
// Initialize posts that should show limited replies (>2 children)
watch(threadedPosts, (newPosts) => {
if (newPosts.length > 0) {
const newCollapsed = new Set(collapsedPosts.value)
const newLimited = new Set<string>()
// Auto-collapse posts with more than 2 direct replies
const addCollapsedPosts = (posts: any[]) => {
// 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) {
newCollapsed.add(post.id)
// Mark this post as having limited replies shown
newLimited.add(post.id)
}
// Recursively check nested replies
if (post.replies?.length > 0) {
addCollapsedPosts(post.replies)
findLimitedPosts(post.replies)
}
})
}
addCollapsedPosts(newPosts)
collapsedPosts.value = newCollapsed
findLimitedPosts(newPosts)
limitedReplyPosts.value = newLimited
}
}, { immediate: true })
@ -112,6 +116,7 @@ const feedDescription = computed(() => {
// Handle reply to note
function onReplyToNote(note: any) {
emit('reply-to-note', {
@ -167,6 +172,21 @@ function onToggleCollapse(postId: string) {
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
}
</script>
<template>
@ -236,6 +256,10 @@ function onToggleCollapse(postId: string) {
<!-- 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">
<!-- Debug info for troubleshooting -->
<div v-if="threadedPosts.length === 0" class="p-4 text-sm text-muted-foreground">
Debug: threadedPosts.length = {{ threadedPosts.length }}, posts.length = {{ notes.length }}
</div>
<div>
<ThreadedPost
v-for="post in threadedPosts"
@ -247,9 +271,11 @@ function onToggleCollapse(postId: string) {
: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"
/>
</div>
</div>

View file

@ -14,19 +14,22 @@ interface Props {
depth?: number
parentCollapsed?: boolean
collapsedPosts?: Set<string>
limitedReplyPosts?: Set<string>
}
interface Emits {
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
(e: 'toggle-like', note: FeedPost): void
(e: 'toggle-collapse', postId: string): void
(e: 'toggle-limited', postId: string): void
}
const props = withDefaults(defineProps<Props>(), {
adminPubkeys: () => [],
depth: 0,
parentCollapsed: false,
collapsedPosts: () => new Set<string>()
collapsedPosts: () => new Set<string>(),
limitedReplyPosts: () => new Set<string>()
})
const emit = defineEmits<Emits>()
@ -34,8 +37,11 @@ const emit = defineEmits<Emits>()
// Check if this post is collapsed based on centralized state
const isCollapsed = computed(() => props.collapsedPosts?.has(props.post.id) || false)
// Check if this post has limited replies showing (only first 2)
const hasLimitedReplies = computed(() => props.limitedReplyPosts?.has(props.post.id) || false)
// Check if this post should be visible (not hidden by parent collapse)
const isVisible = computed(() => !props.parentCollapsed)
const isVisible = computed(() => !props.parentCollapsed && !isCollapsed.value)
// Check if this is an admin post
const isAdminPost = computed(() => props.adminPubkeys.includes(props.post.pubkey))
@ -188,16 +194,13 @@ function getRideshareType(post: FeedPost): string {
</span>
</div>
<!-- Post Content (hidden when collapsed if has replies) -->
<div
v-if="!isCollapsed || !hasReplies"
class="text-sm leading-relaxed whitespace-pre-wrap"
>
<!-- Post Content (always visible for non-collapsed posts) -->
<div class="text-sm leading-relaxed whitespace-pre-wrap">
{{ post.content }}
</div>
<!-- Post Actions (hidden when collapsed) -->
<div v-if="!isCollapsed" class="mt-2">
<!-- Post Actions (always visible) -->
<div class="mt-2">
<div class="flex items-center gap-1">
<!-- Reply Button -->
<Button
@ -240,25 +243,27 @@ function getRideshareType(post: FeedPost): string {
<!-- Render replies recursively -->
<div v-if="hasReplies">
<!-- Show first 2 replies when collapsed, or all when expanded -->
<!-- Show first 2 replies when limited, or all when not limited -->
<ThreadedPost
v-for="reply in isCollapsed ? post.replies?.slice(0, 2) : post.replies"
v-for="reply in hasLimitedReplies ? post.replies?.slice(0, 2) : post.replies"
:key="reply.id"
:post="reply"
:admin-pubkeys="adminPubkeys"
:get-display-name="getDisplayName"
:get-event-reactions="getEventReactions"
:depth="depth + 1"
:parent-collapsed="isCollapsed"
:parent-collapsed="false"
:collapsed-posts="collapsedPosts"
:limited-reply-posts="limitedReplyPosts"
@reply-to-note="$emit('reply-to-note', $event)"
@toggle-like="$emit('toggle-like', $event)"
@toggle-collapse="$emit('toggle-collapse', $event)"
@toggle-limited="$emit('toggle-limited', $event)"
/>
<!-- Show "Load more replies" button when collapsed and there are more than 2 replies -->
<!-- Show "Load more replies" button when limited and there are more than 2 replies -->
<div
v-if="isCollapsed && (post.replies?.length || 0) > 2"
v-if="hasLimitedReplies && (post.replies?.length || 0) > 2"
class="mt-2"
:style="{ marginLeft: `${(depth + 1) * 6}px` }"
>
@ -266,7 +271,7 @@ function getRideshareType(post: FeedPost): string {
variant="ghost"
size="sm"
class="h-6 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50"
@click="toggleCollapse"
@click="() => emit('toggle-limited', post.id)"
>
Show {{ (post.replies?.length || 0) - 2 }} more {{ (post.replies?.length || 0) - 2 === 1 ? 'reply' : 'replies' }}
</Button>

View file

@ -163,21 +163,26 @@ export class FeedService extends BaseService {
filters.push(filter)
}
} else {
// Use legacy feed types
// Handle default feed types (all, announcements, general, etc.)
const filter: Filter = {
kinds: [1], // Text notes by default
kinds: [1], // Text notes
limit: config.maxPosts || 50
}
// Handle legacy feed types
if (config.feedType === 'announcements') {
if (config.adminPubkeys && config.adminPubkeys.length > 0) {
filter.authors = config.adminPubkeys
} else {
// No admin pubkeys configured - fall back to all text posts
console.log('No admin pubkeys configured for announcements feed, showing all posts')
// filter.authors remains undefined, so all authors are included
}
// Apply feed-specific filtering
switch (config.feedType) {
case 'announcements':
if (config.adminPubkeys?.length) {
filter.authors = config.adminPubkeys
}
break
case 'general':
// General posts - no specific author filtering
break
case 'all':
default:
// All posts - no specific filtering
break
}
filters.push(filter)
@ -421,6 +426,8 @@ export class FeedService extends BaseService {
* Build threaded reply structure from flat posts
*/
buildThreadedPosts(posts: FeedPost[]): FeedPost[] {
console.log('FeedService.buildThreadedPosts: Input posts count:', posts.length)
// Create a map for quick lookup
const postMap = new Map<string, FeedPost>()
posts.forEach(post => {

View file

@ -134,6 +134,8 @@
// TODO: Re-enable when push notifications are properly implemented
// import NotificationPermission from '@/components/notifications/NotificationPermission.vue'
import { ref, computed, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Filter, Plus, MessageSquare, Car } from 'lucide-vue-next'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.vue'
@ -185,14 +187,19 @@ const isPresetActive = (presetKey: string) => {
const feedType = computed(() => {
if (selectedFilters.value.length === 0) return 'all'
// Check if it matches a preset
for (const [, presetFilters] of Object.entries(FILTER_PRESETS)) {
if (presetFilters.length === selectedFilters.value.length &&
presetFilters.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))) {
return 'custom' // Always use custom for proper filtering
}
// Check if it matches the 'all' preset - if so, use 'all' feed type for simple filtering
if (selectedFilters.value.length === FILTER_PRESETS.all.length &&
FILTER_PRESETS.all.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))) {
return 'all'
}
// Check if it matches the announcements preset
if (selectedFilters.value.length === FILTER_PRESETS.announcements.length &&
FILTER_PRESETS.announcements.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))) {
return 'announcements'
}
// For all other cases, use custom
return 'custom'
})