diff --git a/src/components/EventFeed.tsx b/src/components/EventFeed.tsx index 170b3b2..9970b95 100644 --- a/src/components/EventFeed.tsx +++ b/src/components/EventFeed.tsx @@ -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 = ({ feedType, onNoteClick }) => { +const EventFeed: React.FC = ({ feedType, onNoteClick, userPubkey }) => { const [userMetadataCache, setUserMetadataCache] = useState>(new Map()) const loadingRef = useRef(null) @@ -31,9 +32,14 @@ const EventFeed: React.FC = ({ 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] diff --git a/src/components/ThreadView.tsx b/src/components/ThreadView.tsx new file mode 100644 index 0000000..1ea0bc6 --- /dev/null +++ b/src/components/ThreadView.tsx @@ -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 = ({ + focusedEvent, + focusedEventMetadata, + onNoteClick +}) => { + const [threadEvents, setThreadEvents] = useState([]) + const [eventMetadata, setEventMetadata] = useState>({}) + const [loading, setLoading] = useState(true) + const [focusedEventId, setFocusedEventId] = useState(focusedEvent.id) + const containerRef = useRef(null) + const focusedNoteRefs = useRef>({}) + + // 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 = {} + + // 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 ( +
+
+
Loading thread...
+
Fetching conversation
+
+
+ ) + } + + return ( +
+
+ Thread • {threadEvents.length} {threadEvents.length === 1 ? 'note' : 'notes'} +
+ + {threadEvents.map((event, index) => { + const isFocused = event.id === focusedEventId + const isFirstFocused = isFocused && index === 0 + const isLastFocused = isFocused && index === threadEvents.length - 1 + + return ( +
{ + 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' : ''} + `} + > + + + {/* Thread connection line for non-last items */} + {index < threadEvents.length - 1 && ( +
+ )} +
+ ) + })} +
+ ) +} + +export default ThreadView \ No newline at end of file diff --git a/src/lib/nostr.ts b/src/lib/nostr.ts index 9717123..6cd1114 100644 --- a/src/lib/nostr.ts +++ b/src/lib/nostr.ts @@ -204,6 +204,69 @@ class NostrService { } } + /** + * Fetch replies to a specific event + */ + async fetchReplies(eventId: string): Promise { + 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 { + 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 */ diff --git a/src/router.tsx b/src/router.tsx index b430bb9..0013525 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -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 */}
{activeTab === 'Global' && } - {activeTab === 'Follows' && } + {activeTab === 'Follows' && } {activeTab === 'Note' && } {activeTab === 'Hashtag' && } {activeTab === 'User' && } @@ -637,13 +638,11 @@ const HeaderRoute = createRootRoute({ {/* Right: thread */}
{selectedNote ? ( -
- -
+ ) : (
Select a note to view thread diff --git a/src/utils/mediaUtils.ts b/src/utils/mediaUtils.ts index 806a64f..d18e475 100644 --- a/src/utils/mediaUtils.ts +++ b/src/utils/mediaUtils.ts @@ -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(