Implement threaded post structure in NostrFeed module

- Introduced a new ThreadedPost component to display posts and their replies in a nested format, enhancing the readability of discussions.
- Updated the useFeed composable to include a computed property for building threaded posts from flat post data.
- Modified FeedService to support the extraction of reply information and build a hierarchical structure for posts, allowing for better organization of replies.
- Removed deprecated rideshare-related functions from NostrFeed.vue, streamlining the component and focusing on the threaded view.

These changes improve the user experience by facilitating clearer interactions within post discussions, promoting engagement and organization in the NostrFeed module.
This commit is contained in:
padreug 2025-09-20 11:44:22 +02:00
parent c027b9ac45
commit ebc7885f04
4 changed files with 374 additions and 150 deletions

View file

@ -1,14 +1,13 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatDistanceToNow } from 'date-fns'
import { Megaphone, RefreshCw, AlertCircle, Reply, Heart, Share } from 'lucide-vue-next'
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 } from '../services/FeedService'
import type { ContentFilter, FeedPost } from '../services/FeedService'
interface Emits {
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
@ -28,7 +27,7 @@ const emit = defineEmits<Emits>()
const adminPubkeys = props.adminPubkeys || appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
// Use centralized feed service - this handles all subscription management and deduplication
const { posts: notes, isLoading, error, refreshFeed } = useFeed({
const { posts: notes, threadedPosts, isLoading, error, refreshFeed } = useFeed({
feedType: props.feedType || 'all',
maxPosts: 100,
adminPubkeys,
@ -85,48 +84,6 @@ const feedDescription = computed(() => {
}
})
// Check if a post is from an admin
function isAdminPost(pubkey: string): boolean {
return adminPubkeys.includes(pubkey)
}
// Check if a post is a rideshare post
function isRidesharePost(note: any): boolean {
// Check for rideshare tags
const hasTags = note.tags?.some((tag: string[]) =>
tag[0] === 't' && ['rideshare', 'carpool'].includes(tag[1])
) || false
// Check for rideshare-specific custom tags
const hasRideshareTypeTags = note.tags?.some((tag: string[]) =>
tag[0] === 'rideshare_type' && ['offering', 'seeking'].includes(tag[1])
) || false
// Check content for rideshare keywords (fallback)
const hasRideshareContent = note.content && (
note.content.includes('🚗 OFFERING RIDE') ||
note.content.includes('🚶 SEEKING RIDE') ||
note.content.includes('#rideshare') ||
note.content.includes('#carpool')
)
return hasTags || hasRideshareTypeTags || hasRideshareContent
}
// Get rideshare type from post
function getRideshareType(note: any): string | null {
// Check custom tags first
const typeTag = note.tags?.find((tag: string[]) => tag[0] === 'rideshare_type')
if (typeTag) {
return typeTag[1] === 'offering' ? 'Offering Ride' : 'Seeking Ride'
}
// Fallback to content analysis
if (note.content?.includes('🚗 OFFERING RIDE')) return 'Offering Ride'
if (note.content?.includes('🚶 SEEKING RIDE')) return 'Seeking Ride'
return 'Rideshare'
}
// Handle reply to note
@ -139,7 +96,7 @@ function onReplyToNote(note: any) {
}
// Handle like/heart reaction toggle
async function onToggleLike(note: any) {
async function onToggleLike(note: FeedPost) {
try {
await toggleLike(note.id, note.pubkey, note.kind)
} catch (error) {
@ -203,7 +160,7 @@ async function onToggleLike(note: any) {
</div>
<!-- No Posts -->
<div v-else-if="notes.length === 0" class="text-center py-8 px-4">
<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>
@ -213,106 +170,20 @@ async function onToggleLike(note: any) {
</p>
</div>
<!-- Posts List - Full height scroll -->
<!-- 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>
<div v-for="(note, index) in notes" :key="note.id" :class="{ 'bg-muted/20': index % 2 === 1 }">
<!-- Text Posts and Other Event Types -->
<div
class="p-3 hover:bg-accent/50 transition-colors border-b border-border/40"
>
<!-- Note Header -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<Badge
v-if="isAdminPost(note.pubkey)"
variant="default"
class="text-xs px-1.5 py-0.5"
>
Admin
</Badge>
<Badge
v-if="note.isReply"
variant="secondary"
class="text-xs px-1.5 py-0.5"
>
Reply
</Badge>
<Badge
v-if="isRidesharePost(note)"
variant="secondary"
class="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
🚗 {{ getRideshareType(note) }}
</Badge>
<span class="text-sm font-medium">{{ getDisplayName(note.pubkey) }}</span>
</div>
<span class="text-xs text-muted-foreground">
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
</span>
</div>
<!-- Note Content -->
<div class="text-sm leading-relaxed whitespace-pre-wrap">
{{ note.content }}
</div>
<!-- Note Actions -->
<div class="mt-2 pt-2 border-t">
<div class="flex items-center justify-between">
<!-- Mentions -->
<div v-if="note.mentions.length > 0" class="flex items-center gap-1 text-xs text-muted-foreground">
<span>Mentions:</span>
<span v-for="mention in note.mentions.slice(0, 2)" :key="mention" class="font-mono">
{{ mention.slice(0, 6) }}...
</span>
<span v-if="note.mentions.length > 2" class="text-muted-foreground">
+{{ note.mentions.length - 2 }} more
</span>
</div>
<!-- Action Buttons - Compact on mobile -->
<div class="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
@click="onReplyToNote(note)"
>
<Reply class="h-3.5 w-3.5 sm:mr-1" />
<span class="hidden sm:inline">Reply</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
:class="{ 'text-red-500 hover:text-red-600': getEventReactions(note.id).userHasLiked }"
@click="onToggleLike(note)"
>
<Heart
class="h-3.5 w-3.5 sm:mr-1"
:class="{ 'fill-current': getEventReactions(note.id).userHasLiked }"
/>
<span class="hidden sm:inline">
{{ getEventReactions(note.id).userHasLiked ? 'Liked' : 'Like' }}
</span>
<span v-if="getEventReactions(note.id).likes > 0" class="ml-1 text-xs">
{{ getEventReactions(note.id).likes }}
</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
>
<Share class="h-3.5 w-3.5 sm:mr-1" />
<span class="hidden sm:inline">Share</span>
</Button>
</div>
</div>
</div>
</div>
</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"
@reply-to-note="onReplyToNote"
@toggle-like="onToggleLike"
/>
</div>
</div>
</div>