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

@ -0,0 +1,269 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatDistanceToNow } from 'date-fns'
import { Reply, Heart, Share, ChevronUp, ChevronDown } from 'lucide-vue-next'
import type { FeedPost } from '../services/FeedService'
interface Props {
post: FeedPost
adminPubkeys?: string[]
getDisplayName: (pubkey: string) => string
getEventReactions: (eventId: string) => { likes: number; userHasLiked: boolean }
depth?: number
}
interface Emits {
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
(e: 'toggle-like', note: FeedPost): void
}
const props = withDefaults(defineProps<Props>(), {
adminPubkeys: () => [],
depth: 0
})
const emit = defineEmits<Emits>()
// Collapse state for this post's replies
const isCollapsed = ref(false)
// Check if this is an admin post
const isAdminPost = computed(() => props.adminPubkeys.includes(props.post.pubkey))
// Check if post has replies
const hasReplies = computed(() => props.post.replies && props.post.replies.length > 0)
// Count total nested replies
const replyCount = computed(() => {
const countReplies = (post: FeedPost): number => {
let count = (post.replies?.length || 0)
post.replies?.forEach(reply => {
count += countReplies(reply)
})
return count
}
return countReplies(props.post)
})
// Toggle collapse state
function toggleCollapse() {
isCollapsed.value = !isCollapsed.value
}
// Handle reply action
function onReplyToNote() {
emit('reply-to-note', {
id: props.post.id,
content: props.post.content,
pubkey: props.post.pubkey
})
}
// Handle like action
function onToggleLike() {
emit('toggle-like', props.post)
}
// Check if a post is a rideshare post (for badge display)
function isRidesharePost(post: FeedPost): boolean {
const hasTags = post.tags?.some((tag: string[]) =>
tag[0] === 't' && ['rideshare', 'carpool'].includes(tag[1])
) || false
const hasRideshareTypeTags = post.tags?.some((tag: string[]) =>
tag[0] === 'rideshare_type' && ['offering', 'seeking'].includes(tag[1])
) || false
const hasRideshareContent = !!(post.content && (
post.content.includes('🚗 OFFERING RIDE') ||
post.content.includes('🚶 SEEKING RIDE') ||
post.content.includes('#rideshare') ||
post.content.includes('#carpool')
))
return hasTags || hasRideshareTypeTags || hasRideshareContent
}
// Get rideshare type from post
function getRideshareType(post: FeedPost): string {
const typeTag = post.tags?.find((tag: string[]) => tag[0] === 'rideshare_type')
if (typeTag) {
return typeTag[1] === 'offering' ? 'Offering Ride' : 'Seeking Ride'
}
if (post.content?.includes('🚗 OFFERING RIDE')) return 'Offering Ride'
if (post.content?.includes('🚶 SEEKING RIDE')) return 'Seeking Ride'
return 'Rideshare'
}
</script>
<template>
<div class="relative">
<!-- Vertical line connecting to parent (for nested replies) -->
<div
v-if="depth > 0"
class="absolute left-0 top-0 bottom-0 w-px bg-border/40"
:style="{ marginLeft: `${(depth - 1) * 24 + 12}px` }"
/>
<!-- Post container with indentation -->
<div
:class="{
'pl-6': depth > 0,
'hover:bg-accent/50': true,
'transition-colors': true,
'border-b': depth === 0,
'border-border/40': depth === 0
}"
:style="{ marginLeft: `${depth * 24}px` }"
>
<div class="p-3 relative">
<!-- Horizontal line from vertical line to content (for nested replies) -->
<div
v-if="depth > 0"
class="absolute left-0 top-7 w-3 h-px bg-border/40"
:style="{ marginLeft: `-${depth * 24}px` }"
/>
<!-- Post Header -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<!-- Collapse button for posts with replies -->
<Button
v-if="hasReplies"
variant="ghost"
size="sm"
class="h-6 w-6 p-0"
@click="toggleCollapse"
>
<ChevronDown v-if="!isCollapsed" class="h-4 w-4" />
<ChevronUp v-else class="h-4 w-4" />
</Button>
<div v-else class="w-6" />
<!-- Badges -->
<Badge
v-if="isAdminPost"
variant="default"
class="text-xs px-1.5 py-0.5"
>
Admin
</Badge>
<Badge
v-if="post.isReply && depth === 0"
variant="secondary"
class="text-xs px-1.5 py-0.5"
>
Reply
</Badge>
<Badge
v-if="isRidesharePost(post)"
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(post) }}
</Badge>
<!-- Author name -->
<span class="text-sm font-medium">{{ getDisplayName(post.pubkey) }}</span>
<!-- Reply count badge if collapsed -->
<Badge
v-if="isCollapsed && hasReplies"
variant="outline"
class="text-xs px-1.5 py-0.5"
>
{{ replyCount }} {{ replyCount === 1 ? 'reply' : 'replies' }}
</Badge>
</div>
<!-- Timestamp -->
<span class="text-xs text-muted-foreground">
{{ formatDistanceToNow(post.created_at * 1000, { addSuffix: true }) }}
</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 }}
</div>
<!-- Post Actions (hidden when collapsed) -->
<div v-if="!isCollapsed" class="mt-2 pt-2 border-t">
<div class="flex items-center justify-between">
<!-- Mentions -->
<div v-if="post.mentions.length > 0" class="flex items-center gap-1 text-xs text-muted-foreground">
<span>Mentions:</span>
<span v-for="mention in post.mentions.slice(0, 2)" :key="mention" class="font-mono">
{{ mention.slice(0, 6) }}...
</span>
<span v-if="post.mentions.length > 2" class="text-muted-foreground">
+{{ post.mentions.length - 2 }} more
</span>
</div>
<!-- Action Buttons -->
<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"
>
<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(post.id).userHasLiked }"
@click="onToggleLike"
>
<Heart
class="h-3.5 w-3.5 sm:mr-1"
:class="{ 'fill-current': getEventReactions(post.id).userHasLiked }"
/>
<span class="hidden sm:inline">
{{ getEventReactions(post.id).userHasLiked ? 'Liked' : 'Like' }}
</span>
<span v-if="getEventReactions(post.id).likes > 0" class="ml-1 text-xs">
{{ getEventReactions(post.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>
<!-- Render replies recursively (hidden when collapsed) -->
<div v-if="!isCollapsed && hasReplies">
<ThreadedPost
v-for="reply in post.replies"
:key="reply.id"
:post="reply"
:admin-pubkeys="adminPubkeys"
:get-display-name="getDisplayName"
:get-event-reactions="getEventReactions"
:depth="depth + 1"
@reply-to-note="$emit('reply-to-note', $event)"
@toggle-like="$emit('toggle-like', $event)"
/>
</div>
</div>
</div>
</template>