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 {
|
interface EventFeedProps {
|
||||||
feedType: 'global' | 'follows' | 'note' | 'hashtag' | 'user' | 'relay'
|
feedType: 'global' | 'follows' | 'note' | 'hashtag' | 'user' | 'relay'
|
||||||
onNoteClick?: (event: NostrEvent, metadata?: UserMetadata | null) => void
|
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 [userMetadataCache, setUserMetadataCache] = useState<Map<string, UserMetadata | null>>(new Map())
|
||||||
const loadingRef = useRef<HTMLDivElement>(null)
|
const loadingRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -31,9 +32,14 @@ const EventFeed: React.FC<EventFeedProps> = ({ feedType, onNoteClick }) => {
|
|||||||
|
|
||||||
// Add feed-specific filters
|
// Add feed-specific filters
|
||||||
if (feedType === 'follows') {
|
if (feedType === 'follows') {
|
||||||
// For follows feed, you would typically filter by followed pubkeys
|
// For follows feed, fetch the user's follow list first
|
||||||
// This is a placeholder - implement based on your follow list logic
|
if (userPubkey) {
|
||||||
fetchParams.authors = [] // Add followed pubkey list here
|
const followedPubkeys = await nostrService.fetchFollowList(userPubkey)
|
||||||
|
fetchParams.authors = followedPubkeys
|
||||||
|
} else {
|
||||||
|
// If no user is logged in, return empty results
|
||||||
|
return []
|
||||||
|
}
|
||||||
} else if (feedType === 'note') {
|
} else if (feedType === 'note') {
|
||||||
// Filter for text notes only (kind 1)
|
// Filter for text notes only (kind 1)
|
||||||
fetchParams.kinds = [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
|
* Close all relay connections
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import orlyImg from '../docs/orly.png'
|
|||||||
import { nostrService, UserMetadata, NostrEvent } from './lib/nostr'
|
import { nostrService, UserMetadata, NostrEvent } from './lib/nostr'
|
||||||
import EventFeed from './components/EventFeed'
|
import EventFeed from './components/EventFeed'
|
||||||
import NoteCard from './components/NoteCard'
|
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))
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
||||||
|
|
||||||
@@ -542,7 +543,7 @@ const HeaderRoute = createRootRoute({
|
|||||||
{/* Left: main */}
|
{/* Left: main */}
|
||||||
<div className="pane overflow-y-scroll">
|
<div className="pane overflow-y-scroll">
|
||||||
{activeTab === 'Global' && <EventFeed feedType="global" onNoteClick={handleNoteClick} />}
|
{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 === 'Note' && <EventFeed feedType="note" onNoteClick={handleNoteClick} />}
|
||||||
{activeTab === 'Hashtag' && <EventFeed feedType="hashtag" onNoteClick={handleNoteClick} />}
|
{activeTab === 'Hashtag' && <EventFeed feedType="hashtag" onNoteClick={handleNoteClick} />}
|
||||||
{activeTab === 'User' && <EventFeed feedType="user" onNoteClick={handleNoteClick} />}
|
{activeTab === 'User' && <EventFeed feedType="user" onNoteClick={handleNoteClick} />}
|
||||||
@@ -637,13 +638,11 @@ const HeaderRoute = createRootRoute({
|
|||||||
{/* Right: thread */}
|
{/* Right: thread */}
|
||||||
<div className="pane overflow-y-scroll bg-[#263238]">
|
<div className="pane overflow-y-scroll bg-[#263238]">
|
||||||
{selectedNote ? (
|
{selectedNote ? (
|
||||||
<div className="max-w-2xl mx-auto">
|
<ThreadView
|
||||||
<NoteCard
|
focusedEvent={selectedNote}
|
||||||
event={selectedNote}
|
focusedEventMetadata={selectedNoteMetadata}
|
||||||
userMetadata={selectedNoteMetadata}
|
onNoteClick={handleNoteClick}
|
||||||
onNoteClick={handleNoteClick}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center">
|
<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>
|
<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'
|
className: 'text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words mb-3'
|
||||||
},
|
},
|
||||||
noteEvent.content.length > 200 ?
|
...linkifyContent(
|
||||||
noteEvent.content.slice(0, 200) + '...' :
|
noteEvent.content,
|
||||||
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
|
// Reaction buttons row
|
||||||
React.createElement(
|
React.createElement(
|
||||||
|
|||||||
Reference in New Issue
Block a user