Add ThreadView component and enhance EventFeed interactivity

- Introduced `ThreadView` for displaying note threads with smooth scroll-to-focus functionality.
- Enhanced `EventFeed` to support user-specific `follows` feed using `userPubkey`.
- Updated `mediaUtils` to linkify note content dynamically and provide click handlers.
- Refactored router to incorporate `ThreadView` in the right panel and streamline note interactions.
- Added methods in `nostrService` for fetching replies and user follow lists.
This commit is contained in:
2025-10-03 14:44:38 +01:00
parent 42b3e65313
commit d22f8dfec4
5 changed files with 301 additions and 15 deletions

View File

@@ -6,9 +6,10 @@ import NoteCard from './NoteCard'
interface EventFeedProps {
feedType: 'global' | 'follows' | 'note' | 'hashtag' | 'user' | 'relay'
onNoteClick?: (event: NostrEvent, metadata?: UserMetadata | null) => void
userPubkey?: string | null
}
const EventFeed: React.FC<EventFeedProps> = ({ feedType, onNoteClick }) => {
const EventFeed: React.FC<EventFeedProps> = ({ feedType, onNoteClick, userPubkey }) => {
const [userMetadataCache, setUserMetadataCache] = useState<Map<string, UserMetadata | null>>(new Map())
const loadingRef = useRef<HTMLDivElement>(null)
@@ -31,9 +32,14 @@ const EventFeed: React.FC<EventFeedProps> = ({ feedType, onNoteClick }) => {
// Add feed-specific filters
if (feedType === 'follows') {
// For follows feed, you would typically filter by followed pubkeys
// This is a placeholder - implement based on your follow list logic
fetchParams.authors = [] // Add followed pubkey list here
// For follows feed, fetch the user's follow list first
if (userPubkey) {
const followedPubkeys = await nostrService.fetchFollowList(userPubkey)
fetchParams.authors = followedPubkeys
} else {
// If no user is logged in, return empty results
return []
}
} else if (feedType === 'note') {
// Filter for text notes only (kind 1)
fetchParams.kinds = [1]

View File

@@ -0,0 +1,212 @@
import React, { useState, useEffect, useRef } from 'react'
import { NostrEvent, UserMetadata, nostrService } from '../lib/nostr'
import NoteCard from './NoteCard'
interface ThreadViewProps {
focusedEvent: NostrEvent
focusedEventMetadata?: UserMetadata | null
onNoteClick?: (event: NostrEvent, metadata?: UserMetadata | null) => void
}
const ThreadView: React.FC<ThreadViewProps> = ({
focusedEvent,
focusedEventMetadata,
onNoteClick
}) => {
const [threadEvents, setThreadEvents] = useState<NostrEvent[]>([])
const [eventMetadata, setEventMetadata] = useState<Record<string, UserMetadata | null>>({})
const [loading, setLoading] = useState(true)
const [focusedEventId, setFocusedEventId] = useState(focusedEvent.id)
const containerRef = useRef<HTMLDivElement>(null)
const focusedNoteRefs = useRef<Record<string, HTMLDivElement | null>>({})
// Update focused event when prop changes
useEffect(() => {
setFocusedEventId(focusedEvent.id)
}, [focusedEvent.id])
// Scroll focused note to center when it changes or thread loads
useEffect(() => {
if (!loading && focusedEventId && threadEvents.length > 0) {
const focusedElement = focusedNoteRefs.current[focusedEventId]
const container = containerRef.current?.closest('.pane') as HTMLElement // Find the scrollable parent
if (focusedElement && container) {
// Use setTimeout to ensure DOM is fully rendered
setTimeout(() => {
const containerRect = container.getBoundingClientRect()
const elementRect = focusedElement.getBoundingClientRect()
// Calculate the position to center the element vertically
const containerCenter = containerRect.height / 2
const elementCenter = elementRect.height / 2
const scrollTop = container.scrollTop + elementRect.top - containerRect.top - containerCenter + elementCenter
// Smooth scroll to center the focused note
container.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
}, 100)
}
}
}, [focusedEventId, loading, threadEvents])
// Fetch thread and metadata
useEffect(() => {
const fetchThread = async () => {
setLoading(true)
try {
// Start with the focused event
const events = [focusedEvent]
const metadataMap: Record<string, UserMetadata | null> = {}
// Add focused event metadata if provided
if (focusedEventMetadata) {
metadataMap[focusedEvent.pubkey] = focusedEventMetadata
}
// Find root event by following 'e' tags (replies)
let currentEvent = focusedEvent
const processedIds = new Set([focusedEvent.id])
// Traverse up the thread to find parent events
while (currentEvent) {
const eTags = currentEvent.tags?.filter(tag => tag[0] === 'e') || []
if (eTags.length === 0) break
// Get the parent event ID (usually the first 'e' tag or the one marked as 'reply')
let parentId = null
const replyTag = eTags.find(tag => tag[3] === 'reply')
if (replyTag) {
parentId = replyTag[1]
} else if (eTags.length > 0) {
parentId = eTags[0][1]
}
if (!parentId || processedIds.has(parentId)) break
try {
const parentEvent = await nostrService.fetchEventById(parentId)
if (parentEvent) {
events.unshift(parentEvent) // Add to beginning to maintain chronological order
processedIds.add(parentEvent.id)
currentEvent = parentEvent
} else {
break
}
} catch (error) {
console.warn('Failed to fetch parent event:', parentId, error)
break
}
}
// Find replies to events in the thread
for (const event of events) {
try {
const replies = await nostrService.fetchReplies(event.id)
for (const reply of replies) {
if (!processedIds.has(reply.id)) {
events.push(reply)
processedIds.add(reply.id)
}
}
} catch (error) {
console.warn('Failed to fetch replies for event:', event.id, error)
}
}
// Sort events chronologically
events.sort((a, b) => a.created_at - b.created_at)
// Fetch metadata for all unique pubkeys
const uniquePubkeys = [...new Set(events.map(event => event.pubkey))]
await Promise.all(
uniquePubkeys.map(async (pubkey) => {
if (!metadataMap[pubkey]) {
try {
const metadata = await nostrService.fetchUserMetadata(pubkey)
metadataMap[pubkey] = metadata
} catch (error) {
console.warn('Failed to fetch metadata for pubkey:', pubkey, error)
metadataMap[pubkey] = null
}
}
})
)
setThreadEvents(events)
setEventMetadata(metadataMap)
} catch (error) {
console.error('Failed to fetch thread:', error)
// Fallback to just the focused event
setThreadEvents([focusedEvent])
setEventMetadata(focusedEventMetadata ? { [focusedEvent.pubkey]: focusedEventMetadata } : {})
} finally {
setLoading(false)
}
}
fetchThread()
}, [focusedEvent, focusedEventMetadata])
// Handle note click to focus on clicked note
const handleThreadNoteClick = (event: NostrEvent, metadata?: UserMetadata | null) => {
setFocusedEventId(event.id)
onNoteClick?.(event, metadata)
}
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-pulse mb-2">Loading thread...</div>
<div className="text-sm text-gray-400">Fetching conversation</div>
</div>
</div>
)
}
return (
<div ref={containerRef} className="max-w-2xl mx-auto">
<div className="mb-4 px-4 py-2 text-sm text-gray-400 border-b border-gray-600">
<span>Thread {threadEvents.length} {threadEvents.length === 1 ? 'note' : 'notes'}</span>
</div>
{threadEvents.map((event, index) => {
const isFocused = event.id === focusedEventId
const isFirstFocused = isFocused && index === 0
const isLastFocused = isFocused && index === threadEvents.length - 1
return (
<div
key={event.id}
ref={(el) => {
if (isFocused) {
focusedNoteRefs.current[event.id] = el
}
}}
className={`
transition-all duration-200
${isFocused ? 'bg-blue-500/10 border-l-4 border-blue-500' : 'hover:bg-black/5'}
${!isFirstFocused ? 'border-t border-gray-700/50' : ''}
`}
>
<NoteCard
event={event}
userMetadata={eventMetadata[event.pubkey]}
onNoteClick={handleThreadNoteClick}
/>
{/* Thread connection line for non-last items */}
{index < threadEvents.length - 1 && (
<div className="ml-8 border-l-2 border-gray-600/30 h-4 -mb-2"></div>
)}
</div>
)
})}
</div>
)
}
export default ThreadView

View File

@@ -204,6 +204,69 @@ class NostrService {
}
}
/**
* Fetch replies to a specific event
*/
async fetchReplies(eventId: string): Promise<NostrEvent[]> {
try {
const filter: Filter = {
kinds: [1], // Text notes (replies)
'#e': [eventId], // Events that reference this event
limit: 100
}
const events = await this.pool.querySync(this.relays, filter)
const replies = events as NostrEvent[]
// Cache the replies
if (replies.length > 0) {
await this.cacheEvents(replies)
}
return replies
} catch (error) {
console.error('Failed to fetch replies:', error)
return []
}
}
/**
* Fetch follow list (kind 3) for a given pubkey
*/
async fetchFollowList(pubkey: string): Promise<string[]> {
try {
const filter: Filter = {
kinds: [3], // Follow list
authors: [pubkey],
limit: 1
}
const events = await this.pool.querySync(this.relays, filter)
if (events.length === 0) {
return []
}
// Get the most recent follow list event
const event = events[0]
// Extract pubkeys from 'p' tags
const followedPubkeys: string[] = []
if (event.tags) {
for (const tag of event.tags) {
if (tag[0] === 'p' && tag[1]) {
followedPubkeys.push(tag[1])
}
}
}
return followedPubkeys
} catch (error) {
console.error('Failed to fetch follow list:', error)
return []
}
}
/**
* Close all relay connections
*/

View File

@@ -10,6 +10,7 @@ import orlyImg from '../docs/orly.png'
import { nostrService, UserMetadata, NostrEvent } from './lib/nostr'
import EventFeed from './components/EventFeed'
import NoteCard from './components/NoteCard'
import ThreadView from './components/ThreadView'
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
@@ -542,7 +543,7 @@ const HeaderRoute = createRootRoute({
{/* Left: main */}
<div className="pane overflow-y-scroll">
{activeTab === 'Global' && <EventFeed feedType="global" onNoteClick={handleNoteClick} />}
{activeTab === 'Follows' && <EventFeed feedType="follows" onNoteClick={handleNoteClick} />}
{activeTab === 'Follows' && <EventFeed feedType="follows" onNoteClick={handleNoteClick} userPubkey={pubkey} />}
{activeTab === 'Note' && <EventFeed feedType="note" onNoteClick={handleNoteClick} />}
{activeTab === 'Hashtag' && <EventFeed feedType="hashtag" onNoteClick={handleNoteClick} />}
{activeTab === 'User' && <EventFeed feedType="user" onNoteClick={handleNoteClick} />}
@@ -637,13 +638,11 @@ const HeaderRoute = createRootRoute({
{/* Right: thread */}
<div className="pane overflow-y-scroll bg-[#263238]">
{selectedNote ? (
<div className="max-w-2xl mx-auto">
<NoteCard
event={selectedNote}
userMetadata={selectedNoteMetadata}
onNoteClick={handleNoteClick}
/>
</div>
<ThreadView
focusedEvent={selectedNote}
focusedEventMetadata={selectedNoteMetadata}
onNoteClick={handleNoteClick}
/>
) : (
<div className="h-full flex items-center justify-center">
<span className="text-xl tracking-wide text-gray-400">Select a note to view thread</span>

View File

@@ -423,9 +423,15 @@ const EmbeddedNote: React.FC<{ reference: NostrReference; index: number }> = ({
{
className: 'text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words mb-3'
},
noteEvent.content.length > 200 ?
noteEvent.content.slice(0, 200) + '...' :
noteEvent.content
...linkifyContent(
noteEvent.content,
(url: string, type: MediaType) => {
console.log('Media clicked in embedded note:', url, type)
},
(reference: NostrReference) => {
console.log('Nostr reference clicked in embedded note:', reference)
}
)
),
// Reaction buttons row
React.createElement(