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:
parent
c027b9ac45
commit
ebc7885f04
4 changed files with 374 additions and 150 deletions
|
|
@ -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>
|
||||
|
|
|
|||
269
src/modules/nostr-feed/components/ThreadedPost.vue
Normal file
269
src/modules/nostr-feed/components/ThreadedPost.vue
Normal 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>
|
||||
|
|
@ -33,6 +33,12 @@ export function useFeed(config: UseFeedConfig) {
|
|||
return feedService.getFilteredPosts(feedConfig)
|
||||
})
|
||||
|
||||
const threadedPosts = computed(() => {
|
||||
if (!feedService) return []
|
||||
const posts = feedService.getFilteredPosts(feedConfig)
|
||||
return feedService.buildThreadedPosts(posts)
|
||||
})
|
||||
|
||||
const loadFeed = async () => {
|
||||
console.log('useFeed: loadFeed called, feedService available:', !!feedService)
|
||||
if (!feedService) {
|
||||
|
|
@ -85,6 +91,7 @@ export function useFeed(config: UseFeedConfig) {
|
|||
|
||||
return {
|
||||
posts: filteredPosts,
|
||||
threadedPosts,
|
||||
isLoading: feedService?.isLoading ?? ref(false),
|
||||
error: feedService?.error ?? ref(null),
|
||||
refreshFeed,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ export interface FeedPost {
|
|||
tags: string[][]
|
||||
mentions: string[]
|
||||
isReply: boolean
|
||||
replyTo?: string
|
||||
replyTo?: string // Direct parent ID
|
||||
rootId?: string // Thread root ID
|
||||
replies?: FeedPost[] // Child replies
|
||||
depth?: number // Depth in reply tree (0 for root posts)
|
||||
}
|
||||
|
||||
export interface ContentFilter {
|
||||
|
|
@ -234,6 +237,36 @@ export class FeedService extends BaseService {
|
|||
return
|
||||
}
|
||||
|
||||
// Extract reply information according to NIP-10
|
||||
let rootId: string | undefined
|
||||
let replyTo: string | undefined
|
||||
let isReply = false
|
||||
|
||||
// Look for marked e-tags first (preferred method)
|
||||
const markedRootTag = event.tags?.find((tag: string[]) => tag[0] === 'e' && tag[3] === 'root')
|
||||
const markedReplyTag = event.tags?.find((tag: string[]) => tag[0] === 'e' && tag[3] === 'reply')
|
||||
|
||||
if (markedRootTag || markedReplyTag) {
|
||||
// Using marked tags (NIP-10 preferred method)
|
||||
rootId = markedRootTag?.[1]
|
||||
replyTo = markedReplyTag?.[1] || markedRootTag?.[1] // Direct reply to root if no reply tag
|
||||
isReply = true
|
||||
} else {
|
||||
// Fallback to positional tags (deprecated but still in use)
|
||||
const eTags = event.tags?.filter((tag: string[]) => tag[0] === 'e') || []
|
||||
if (eTags.length === 1) {
|
||||
// Single e-tag means this is a direct reply
|
||||
replyTo = eTags[0][1]
|
||||
rootId = eTags[0][1]
|
||||
isReply = true
|
||||
} else if (eTags.length >= 2) {
|
||||
// Multiple e-tags: first is root, last is direct reply
|
||||
rootId = eTags[0][1]
|
||||
replyTo = eTags[eTags.length - 1][1]
|
||||
isReply = true
|
||||
}
|
||||
}
|
||||
|
||||
// Transform to FeedPost
|
||||
const post: FeedPost = {
|
||||
id: event.id,
|
||||
|
|
@ -243,8 +276,11 @@ export class FeedService extends BaseService {
|
|||
kind: event.kind,
|
||||
tags: event.tags || [],
|
||||
mentions: event.tags?.filter((tag: string[]) => tag[0] === 'p').map((tag: string[]) => tag[1]) || [],
|
||||
isReply: event.tags?.some((tag: string[]) => tag[0] === 'e' && tag[3] === 'reply') || false,
|
||||
replyTo: event.tags?.find((tag: string[]) => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||
isReply,
|
||||
replyTo,
|
||||
rootId,
|
||||
replies: [],
|
||||
depth: 0
|
||||
}
|
||||
|
||||
// Add to posts (newest first)
|
||||
|
|
@ -378,6 +414,47 @@ export class FeedService extends BaseService {
|
|||
await this.subscribeFeed(this.currentConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build threaded reply structure from flat posts
|
||||
*/
|
||||
buildThreadedPosts(posts: FeedPost[]): FeedPost[] {
|
||||
// Create a map for quick lookup
|
||||
const postMap = new Map<string, FeedPost>()
|
||||
posts.forEach(post => {
|
||||
postMap.set(post.id, { ...post, replies: [], depth: 0 })
|
||||
})
|
||||
|
||||
// Build the tree structure
|
||||
const rootPosts: FeedPost[] = []
|
||||
|
||||
posts.forEach(post => {
|
||||
const currentPost = postMap.get(post.id)!
|
||||
|
||||
if (post.isReply && post.replyTo) {
|
||||
// This is a reply, attach it to its parent if parent exists
|
||||
const parentPost = postMap.get(post.replyTo)
|
||||
if (parentPost) {
|
||||
currentPost.depth = (parentPost.depth || 0) + 1
|
||||
parentPost.replies = parentPost.replies || []
|
||||
parentPost.replies.push(currentPost)
|
||||
// Sort replies by timestamp (oldest first for better reading flow)
|
||||
parentPost.replies.sort((a, b) => a.created_at - b.created_at)
|
||||
} else {
|
||||
// Parent not found, treat as root post
|
||||
rootPosts.push(currentPost)
|
||||
}
|
||||
} else {
|
||||
// This is a root post
|
||||
rootPosts.push(currentPost)
|
||||
}
|
||||
})
|
||||
|
||||
// Sort root posts by newest first
|
||||
rootPosts.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
return rootPosts
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered posts for specific feed type
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue