diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index 4cddac0..b4a5fa6 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -1,14 +1,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Admin + + + Reply + + + 🚗 {{ getRideshareType(post) }} + + + + {{ getDisplayName(post.pubkey) }} + + + + {{ replyCount }} {{ replyCount === 1 ? 'reply' : 'replies' }} + + + + + + {{ formatDistanceToNow(post.created_at * 1000, { addSuffix: true }) }} + + + + + + {{ post.content }} + + + + + + + + Mentions: + + {{ mention.slice(0, 6) }}... + + + +{{ post.mentions.length - 2 }} more + + + + + + + + Reply + + + + + {{ getEventReactions(post.id).userHasLiked ? 'Liked' : 'Like' }} + + + {{ getEventReactions(post.id).likes }} + + + + + Share + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/nostr-feed/composables/useFeed.ts b/src/modules/nostr-feed/composables/useFeed.ts index 39f8f31..b70458e 100644 --- a/src/modules/nostr-feed/composables/useFeed.ts +++ b/src/modules/nostr-feed/composables/useFeed.ts @@ -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, diff --git a/src/modules/nostr-feed/services/FeedService.ts b/src/modules/nostr-feed/services/FeedService.ts index a161dd9..c15bd04 100644 --- a/src/modules/nostr-feed/services/FeedService.ts +++ b/src/modules/nostr-feed/services/FeedService.ts @@ -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() + 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 */