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:
@@ -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]
|
||||
|
||||
212
src/components/ThreadView.tsx
Normal file
212
src/components/ThreadView.tsx
Normal 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
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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}
|
||||
<ThreadView
|
||||
focusedEvent={selectedNote}
|
||||
focusedEventMetadata={selectedNoteMetadata}
|
||||
onNoteClick={handleNoteClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user