Browse Source

fix profile page

imwald
Silberengel 5 months ago
parent
commit
0e8199cdc7
  1. 343
      src/components/Profile/ProfileBookmarksAndHashtags.tsx
  2. 191
      src/components/Profile/ProfileFeed.tsx
  3. 44
      src/components/Profile/index.tsx
  4. 74
      src/components/ui/ProfileSearchBar.tsx
  5. 55
      src/components/ui/RetroRefreshButton.tsx

343
src/components/Profile/ProfileBookmarksAndHashtags.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState, forwardRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
@ -12,13 +12,11 @@ import { Skeleton } from '../ui/skeleton' @@ -12,13 +12,11 @@ import { Skeleton } from '../ui/skeleton'
type TabValue = 'bookmarks' | 'hashtags' | 'pins'
export default function ProfileBookmarksAndHashtags({
pubkey,
initialTab = 'pins'
}: {
const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, {
pubkey: string
initialTab?: TabValue
}) {
searchQuery?: string
}>(({ pubkey, initialTab = 'pins', searchQuery = '' }, ref) => {
const { t } = useTranslation()
const { pubkey: myPubkey } = useNostr()
const { favoriteRelays } = useFavoriteRelays()
@ -32,6 +30,16 @@ export default function ProfileBookmarksAndHashtags({ @@ -32,6 +30,16 @@ export default function ProfileBookmarksAndHashtags({
const [interestListEvent, setInterestListEvent] = useState<Event | null>(null)
const [pinListEvent, setPinListEvent] = useState<Event | null>(null)
// Retry state for each tab
const [retryCountBookmarks, setRetryCountBookmarks] = useState(0)
const [retryCountHashtags, setRetryCountHashtags] = useState(0)
const [retryCountPins, setRetryCountPins] = useState(0)
const [isRetryingBookmarks, setIsRetryingBookmarks] = useState(false)
const [isRetryingHashtags, setIsRetryingHashtags] = useState(false)
const [isRetryingPins, setIsRetryingPins] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const maxRetries = 3
// Build comprehensive relay list for fetching bookmark and interest list events
// Using the same comprehensive relay list construction as pin lists
const buildComprehensiveRelayList = useCallback(async () => {
@ -57,8 +65,14 @@ export default function ProfileBookmarksAndHashtags({ @@ -57,8 +65,14 @@ export default function ProfileBookmarksAndHashtags({
}, [myPubkey, favoriteRelays])
// Fetch bookmark list event and associated events
const fetchBookmarks = useCallback(async () => {
setLoadingBookmarks(true)
const fetchBookmarks = useCallback(async (isRetry = false, isRefresh = false) => {
if (!isRetry && !isRefresh) {
setLoadingBookmarks(true)
setRetryCountBookmarks(0)
} else if (isRetry) {
setIsRetryingBookmarks(true)
}
try {
const comprehensiveRelays = await buildComprehensiveRelayList()
@ -96,7 +110,19 @@ export default function ProfileBookmarksAndHashtags({ @@ -96,7 +110,19 @@ export default function ProfileBookmarksAndHashtags({
limit: 100
})
logger.debug('[ProfileBookmarksAndHashtags] Fetched', events.length, 'bookmark events')
setBookmarkEvents(events)
if (isRefresh) {
// For refresh, append new events and deduplicate
setBookmarkEvents(prevEvents => {
const existingIds = new Set(prevEvents.map(e => e.id))
const newEvents = events.filter(event => !existingIds.has(event.id))
const combinedEvents = [...newEvents, ...prevEvents]
// Re-sort the combined events
return combinedEvents.sort((a, b) => b.created_at - a.created_at)
})
} else {
setBookmarkEvents(events)
}
} catch (error) {
logger.warn('[ProfileBookmarksAndHashtags] Error fetching bookmark events:', error)
setBookmarkEvents([])
@ -107,17 +133,44 @@ export default function ProfileBookmarksAndHashtags({ @@ -107,17 +133,44 @@ export default function ProfileBookmarksAndHashtags({
} else {
setBookmarkEvents([])
}
// Reset retry count on successful fetch
if (isRetry) {
setRetryCountBookmarks(0)
}
} catch (error) {
logger.component('ProfileBookmarksAndHashtags', 'Error fetching bookmarks', { error: (error as Error).message })
setBookmarkEvents([])
logger.component('ProfileBookmarksAndHashtags', 'Error fetching bookmarks', { error: (error as Error).message, retryCount: isRetry ? retryCountBookmarks + 1 : 0 })
// If this is not a retry and we haven't exceeded max retries, schedule a retry
if (!isRetry && retryCountBookmarks < maxRetries) {
console.log('[ProfileBookmarksAndHashtags] Scheduling bookmark retry', retryCountBookmarks + 1, 'of', maxRetries)
// Use shorter delays for initial retries, then exponential backoff
const delay = retryCountBookmarks === 0 ? 1000 : retryCountBookmarks === 1 ? 2000 : 3000
setTimeout(() => {
setRetryCountBookmarks(prev => prev + 1)
fetchBookmarks(true)
}, delay)
} else {
setBookmarkEvents([])
}
} finally {
setLoadingBookmarks(false)
setIsRetryingBookmarks(false)
if (isRefresh) {
setIsRefreshing(false)
}
}
}, [pubkey, buildComprehensiveRelayList])
}, [pubkey, buildComprehensiveRelayList, retryCountBookmarks, maxRetries])
// Fetch interest list event and associated events
const fetchHashtags = useCallback(async () => {
setLoadingHashtags(true)
const fetchHashtags = useCallback(async (isRetry = false, isRefresh = false) => {
if (!isRetry && !isRefresh) {
setLoadingHashtags(true)
setRetryCountHashtags(0)
} else if (isRetry) {
setIsRetryingHashtags(true)
}
try {
const comprehensiveRelays = await buildComprehensiveRelayList()
@ -155,7 +208,19 @@ export default function ProfileBookmarksAndHashtags({ @@ -155,7 +208,19 @@ export default function ProfileBookmarksAndHashtags({
limit: 100
})
// console.log('[ProfileBookmarksAndHashtags] Fetched', events.length, 'hashtag events')
setHashtagEvents(events)
if (isRefresh) {
// For refresh, append new events and deduplicate
setHashtagEvents(prevEvents => {
const existingIds = new Set(prevEvents.map(e => e.id))
const newEvents = events.filter(event => !existingIds.has(event.id))
const combinedEvents = [...newEvents, ...prevEvents]
// Re-sort the combined events
return combinedEvents.sort((a, b) => b.created_at - a.created_at)
})
} else {
setHashtagEvents(events)
}
} catch (error) {
logger.component('ProfileBookmarksAndHashtags', 'Error fetching hashtag events', { error: (error as Error).message })
setHashtagEvents([])
@ -166,17 +231,44 @@ export default function ProfileBookmarksAndHashtags({ @@ -166,17 +231,44 @@ export default function ProfileBookmarksAndHashtags({
} else {
setHashtagEvents([])
}
// Reset retry count on successful fetch
if (isRetry) {
setRetryCountHashtags(0)
}
} catch (error) {
logger.component('ProfileBookmarksAndHashtags', 'Error fetching hashtags', { error: (error as Error).message })
setHashtagEvents([])
logger.component('ProfileBookmarksAndHashtags', 'Error fetching hashtags', { error: (error as Error).message, retryCount: isRetry ? retryCountHashtags + 1 : 0 })
// If this is not a retry and we haven't exceeded max retries, schedule a retry
if (!isRetry && retryCountHashtags < maxRetries) {
console.log('[ProfileBookmarksAndHashtags] Scheduling hashtag retry', retryCountHashtags + 1, 'of', maxRetries)
// Use shorter delays for initial retries, then exponential backoff
const delay = retryCountHashtags === 0 ? 1000 : retryCountHashtags === 1 ? 2000 : 3000
setTimeout(() => {
setRetryCountHashtags(prev => prev + 1)
fetchHashtags(true)
}, delay)
} else {
setHashtagEvents([])
}
} finally {
setLoadingHashtags(false)
setIsRetryingHashtags(false)
if (isRefresh) {
setIsRefreshing(false)
}
}
}, [pubkey, buildComprehensiveRelayList])
}, [pubkey, buildComprehensiveRelayList, retryCountHashtags, maxRetries])
// Fetch pin list event and associated events
const fetchPins = useCallback(async () => {
setLoadingPins(true)
const fetchPins = useCallback(async (isRetry = false, isRefresh = false) => {
if (!isRetry && !isRefresh) {
setLoadingPins(true)
setRetryCountPins(0)
} else if (isRetry) {
setIsRetryingPins(true)
}
try {
const comprehensiveRelays = await buildComprehensiveRelayList()
@ -218,7 +310,19 @@ export default function ProfileBookmarksAndHashtags({ @@ -218,7 +310,19 @@ export default function ProfileBookmarksAndHashtags({
limit: 100
})
logger.debug('[ProfileBookmarksAndHashtags] Fetched', events.length, 'pin events')
setPinEvents(events)
if (isRefresh) {
// For refresh, append new events and deduplicate
setPinEvents(prevEvents => {
const existingIds = new Set(prevEvents.map(e => e.id))
const newEvents = events.filter(event => !existingIds.has(event.id))
const combinedEvents = [...newEvents, ...prevEvents]
// Re-sort the combined events
return combinedEvents.sort((a, b) => b.created_at - a.created_at)
})
} else {
setPinEvents(events)
}
} catch (error) {
logger.warn('[ProfileBookmarksAndHashtags] Error fetching pin events:', error)
setPinEvents([])
@ -229,21 +333,64 @@ export default function ProfileBookmarksAndHashtags({ @@ -229,21 +333,64 @@ export default function ProfileBookmarksAndHashtags({
} else {
setPinEvents([])
}
// Reset retry count on successful fetch
if (isRetry) {
setRetryCountPins(0)
}
} catch (error) {
logger.component('ProfileBookmarksAndHashtags', 'Error fetching pins', { error: (error as Error).message })
setPinEvents([])
logger.component('ProfileBookmarksAndHashtags', 'Error fetching pins', { error: (error as Error).message, retryCount: isRetry ? retryCountPins + 1 : 0 })
// If this is not a retry and we haven't exceeded max retries, schedule a retry
if (!isRetry && retryCountPins < maxRetries) {
console.log('[ProfileBookmarksAndHashtags] Scheduling pin retry', retryCountPins + 1, 'of', maxRetries)
// Use shorter delays for initial retries, then exponential backoff
const delay = retryCountPins === 0 ? 1000 : retryCountPins === 1 ? 2000 : 3000
setTimeout(() => {
setRetryCountPins(prev => prev + 1)
fetchPins(true)
}, delay)
} else {
setPinEvents([])
}
} finally {
setLoadingPins(false)
setIsRetryingPins(false)
if (isRefresh) {
setIsRefreshing(false)
}
}
}, [pubkey, buildComprehensiveRelayList])
}, [pubkey, buildComprehensiveRelayList, retryCountPins, maxRetries])
// Expose refresh function to parent component
const refresh = useCallback(() => {
setRetryCountBookmarks(0)
setRetryCountHashtags(0)
setRetryCountPins(0)
setIsRefreshing(true)
fetchBookmarks(false, true) // isRetry = false, isRefresh = true
fetchHashtags(false, true) // isRetry = false, isRefresh = true
fetchPins(false, true) // isRetry = false, isRefresh = true
}, [fetchBookmarks, fetchHashtags, fetchPins])
useImperativeHandle(ref, () => ({
refresh
}), [refresh])
// Fetch data when component mounts or pubkey changes
// Fetch data when component mounts or pubkey changes with a small delay
useEffect(() => {
fetchBookmarks()
fetchHashtags()
fetchPins()
}, [fetchBookmarks, fetchHashtags, fetchPins])
if (pubkey) {
// Add a small delay to let the component fully mount and relays to be ready
const timer = setTimeout(() => {
fetchBookmarks()
fetchHashtags()
fetchPins()
}, 500) // 500ms delay
return () => clearTimeout(timer)
}
}, [pubkey]) // Only depend on pubkey to avoid loops
// Check if the requested tab has content
const hasContent = useMemo(() => {
@ -263,19 +410,77 @@ export default function ProfileBookmarksAndHashtags({ @@ -263,19 +410,77 @@ export default function ProfileBookmarksAndHashtags({
const isLoading = useMemo(() => {
switch (initialTab) {
case 'pins':
return loadingPins
return loadingPins || isRetryingPins
case 'bookmarks':
return loadingBookmarks
return loadingBookmarks || isRetryingBookmarks
case 'hashtags':
return loadingHashtags
return loadingHashtags || isRetryingHashtags
default:
return false
}
}, [initialTab, loadingPins, loadingBookmarks, loadingHashtags])
}, [initialTab, loadingPins, loadingBookmarks, loadingHashtags, isRetryingPins, isRetryingBookmarks, isRetryingHashtags])
// Get retry info for current tab
const getRetryInfo = () => {
switch (initialTab) {
case 'pins':
return { isRetrying: isRetryingPins, retryCount: retryCountPins }
case 'bookmarks':
return { isRetrying: isRetryingBookmarks, retryCount: retryCountBookmarks }
case 'hashtags':
return { isRetrying: isRetryingHashtags, retryCount: retryCountHashtags }
default:
return { isRetrying: false, retryCount: 0 }
}
}
const { isRetrying, retryCount } = getRetryInfo()
// Filter events based on search query for each tab
const filteredBookmarkEvents = useMemo(() => {
if (!searchQuery.trim()) return bookmarkEvents
const query = searchQuery.toLowerCase()
return bookmarkEvents.filter(event =>
event.content.toLowerCase().includes(query) ||
event.tags.some(tag =>
tag.length > 1 && tag[1]?.toLowerCase().includes(query)
)
)
}, [bookmarkEvents, searchQuery])
const filteredHashtagEvents = useMemo(() => {
if (!searchQuery.trim()) return hashtagEvents
const query = searchQuery.toLowerCase()
return hashtagEvents.filter(event =>
event.content.toLowerCase().includes(query) ||
event.tags.some(tag =>
tag.length > 1 && tag[1]?.toLowerCase().includes(query)
)
)
}, [hashtagEvents, searchQuery])
const filteredPinEvents = useMemo(() => {
if (!searchQuery.trim()) return pinEvents
const query = searchQuery.toLowerCase()
return pinEvents.filter(event =>
event.content.toLowerCase().includes(query) ||
event.tags.some(tag =>
tag.length > 1 && tag[1]?.toLowerCase().includes(query)
)
)
}, [pinEvents, searchQuery])
if (isLoading) {
return (
<div className="space-y-2">
{isRetrying && retryCount > 0 && (
<div className="text-center py-2 text-sm text-muted-foreground">
Retrying... ({retryCount}/{maxRetries})
</div>
)}
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
@ -291,6 +496,13 @@ export default function ProfileBookmarksAndHashtags({ @@ -291,6 +496,13 @@ export default function ProfileBookmarksAndHashtags({
// Render content based on initial tab
const renderContent = () => {
if (initialTab === 'pins') {
if (isRefreshing) {
return (
<div className="px-4 py-2 text-sm text-green-500 text-center">
🔄 Refreshing pins...
</div>
)
}
if (loadingPins) {
return (
<div className="space-y-2">
@ -309,10 +521,23 @@ export default function ProfileBookmarksAndHashtags({ @@ -309,10 +521,23 @@ export default function ProfileBookmarksAndHashtags({
)
}
if (filteredPinEvents.length === 0 && searchQuery.trim()) {
return (
<div className="text-center py-8 text-muted-foreground">
No pins match your search
</div>
)
}
return (
<div className="min-h-screen">
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredPinEvents.length} of {pinEvents.length} pins
</div>
)}
<div className="space-y-2">
{pinEvents.map((event) => (
{filteredPinEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
@ -326,6 +551,13 @@ export default function ProfileBookmarksAndHashtags({ @@ -326,6 +551,13 @@ export default function ProfileBookmarksAndHashtags({
}
if (initialTab === 'bookmarks') {
if (isRefreshing) {
return (
<div className="px-4 py-2 text-sm text-green-500 text-center">
🔄 Refreshing bookmarks...
</div>
)
}
if (loadingBookmarks) {
return (
<div className="space-y-2">
@ -344,10 +576,23 @@ export default function ProfileBookmarksAndHashtags({ @@ -344,10 +576,23 @@ export default function ProfileBookmarksAndHashtags({
)
}
if (filteredBookmarkEvents.length === 0 && searchQuery.trim()) {
return (
<div className="text-center py-8 text-muted-foreground">
No bookmarks match your search
</div>
)
}
return (
<div className="min-h-screen">
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredBookmarkEvents.length} of {bookmarkEvents.length} bookmarks
</div>
)}
<div className="space-y-2">
{bookmarkEvents.map((event) => (
{filteredBookmarkEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
@ -361,6 +606,13 @@ export default function ProfileBookmarksAndHashtags({ @@ -361,6 +606,13 @@ export default function ProfileBookmarksAndHashtags({
}
if (initialTab === 'hashtags') {
if (isRefreshing) {
return (
<div className="px-4 py-2 text-sm text-green-500 text-center">
🔄 Refreshing interests...
</div>
)
}
if (loadingHashtags) {
return (
<div className="space-y-2">
@ -379,10 +631,23 @@ export default function ProfileBookmarksAndHashtags({ @@ -379,10 +631,23 @@ export default function ProfileBookmarksAndHashtags({
)
}
if (filteredHashtagEvents.length === 0 && searchQuery.trim()) {
return (
<div className="text-center py-8 text-muted-foreground">
No interests match your search
</div>
)
}
return (
<div className="min-h-screen">
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredHashtagEvents.length} of {hashtagEvents.length} interests
</div>
)}
<div className="space-y-2">
{hashtagEvents.map((event) => (
{filteredHashtagEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
@ -399,4 +664,8 @@ export default function ProfileBookmarksAndHashtags({ @@ -399,4 +664,8 @@ export default function ProfileBookmarksAndHashtags({
}
return renderContent()
}
})
ProfileBookmarksAndHashtags.displayName = 'ProfileBookmarksAndHashtags'
export default ProfileBookmarksAndHashtags

191
src/components/Profile/ProfileFeed.tsx

@ -3,7 +3,7 @@ import logger from '@/lib/logger' @@ -3,7 +3,7 @@ import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
import { Event } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, forwardRef, useImperativeHandle, useMemo } from 'react'
import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -11,13 +11,18 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -11,13 +11,18 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
interface ProfileFeedProps {
pubkey: string
topSpace?: number
searchQuery?: string
}
export default function ProfileFeed({ pubkey, topSpace }: ProfileFeedProps) {
const ProfileFeed = forwardRef<{ refresh: () => void }, ProfileFeedProps>(({ pubkey, topSpace, searchQuery = '' }, ref) => {
console.log('[ProfileFeed] Component rendered with pubkey:', pubkey)
const [events, setEvents] = useState<Event[]>([])
const [isLoading, setIsLoading] = useState(true)
const [retryCount, setRetryCount] = useState(0)
const [isRetrying, setIsRetrying] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const { favoriteRelays } = useFavoriteRelays()
const maxRetries = 3
// Build comprehensive relay list including user's personal relays
const buildComprehensiveRelayList = useCallback(async () => {
@ -52,69 +57,135 @@ export default function ProfileFeed({ pubkey, topSpace }: ProfileFeedProps) { @@ -52,69 +57,135 @@ export default function ProfileFeed({ pubkey, topSpace }: ProfileFeedProps) {
}
}, [pubkey, favoriteRelays])
useEffect(() => {
const fetchPosts = async () => {
if (!pubkey) {
setEvents([])
setIsLoading(false)
return
}
const fetchPosts = useCallback(async (isRetry = false, isRefresh = false) => {
if (!pubkey) {
setEvents([])
setIsLoading(false)
return
}
try {
try {
if (!isRetry && !isRefresh) {
setIsLoading(true)
setRetryCount(0)
} else if (isRetry) {
setIsRetrying(true)
} else if (isRefresh) {
setIsRefreshing(true)
}
console.log('[ProfileFeed] Fetching events for pubkey:', pubkey)
console.log('[ProfileFeed] Fetching events for pubkey:', pubkey, isRetry ? `(retry ${retryCount + 1}/${maxRetries})` : '')
// Build comprehensive relay list including user's personal relays
const comprehensiveRelays = await buildComprehensiveRelayList()
console.log('[ProfileFeed] Using comprehensive relay list:', comprehensiveRelays.length, 'relays')
// Build comprehensive relay list including user's personal relays
const comprehensiveRelays = await buildComprehensiveRelayList()
console.log('[ProfileFeed] Using comprehensive relay list:', comprehensiveRelays.length, 'relays')
// First, let's try to fetch ANY events from this user to see if they exist
console.log('[ProfileFeed] Testing: fetching ANY events from this user...')
const anyEvents = await client.fetchEvents(comprehensiveRelays.slice(0, 10), {
authors: [pubkey],
limit: 10
})
console.log('[ProfileFeed] Found ANY events:', anyEvents.length)
if (anyEvents.length > 0) {
console.log('[ProfileFeed] Sample ANY events:', anyEvents.map(e => ({ kind: e.kind, id: e.id, content: e.content?.substring(0, 30) + '...' })))
}
// Now try to fetch text notes specifically
const allEvents = await client.fetchEvents(comprehensiveRelays, {
authors: [pubkey],
kinds: [1], // Text notes only
limit: 100
})
// Now try to fetch text notes specifically
const allEvents = await client.fetchEvents(comprehensiveRelays, {
authors: [pubkey],
kinds: [1], // Text notes only
limit: 100
})
console.log('[ProfileFeed] Fetched total events:', allEvents.length)
console.log('[ProfileFeed] Sample events:', allEvents.slice(0, 3).map(e => ({ id: e.id, content: e.content.substring(0, 50) + '...', tags: e.tags.slice(0, 3) })))
console.log('[ProfileFeed] Fetched total events:', allEvents.length)
console.log('[ProfileFeed] Sample events:', allEvents.slice(0, 3).map(e => ({ id: e.id, content: e.content.substring(0, 50) + '...', tags: e.tags.slice(0, 3) })))
// Show ALL events (both top-level posts and replies)
console.log('[ProfileFeed] Showing all events (posts + replies):', allEvents.length)
console.log('[ProfileFeed] Events sample:', allEvents.slice(0, 2).map(e => ({ id: e.id, content: e.content.substring(0, 50) + '...' })))
// Show ALL events (both top-level posts and replies)
console.log('[ProfileFeed] Showing all events (posts + replies):', allEvents.length)
console.log('[ProfileFeed] Events sample:', allEvents.slice(0, 2).map(e => ({ id: e.id, content: e.content.substring(0, 50) + '...' })))
const eventsToShow = allEvents
const eventsToShow = allEvents
// Sort by creation time (newest first)
eventsToShow.sort((a, b) => b.created_at - a.created_at)
// Sort by creation time (newest first)
eventsToShow.sort((a, b) => b.created_at - a.created_at)
if (isRefresh) {
// For refresh, append new events and deduplicate
setEvents(prevEvents => {
const existingIds = new Set(prevEvents.map(e => e.id))
const newEvents = eventsToShow.filter(event => !existingIds.has(event.id))
const combinedEvents = [...newEvents, ...prevEvents]
// Re-sort the combined events
return combinedEvents.sort((a, b) => b.created_at - a.created_at)
})
} else {
// For initial load or retry, replace events
setEvents(eventsToShow)
} catch (error) {
console.error('[ProfileFeed] Error fetching events:', error)
logger.component('ProfileFeed', 'Initialization failed', { pubkey, error: (error as Error).message })
}
// Reset retry count on successful fetch
if (isRetry) {
setRetryCount(0)
}
} catch (error) {
console.error('[ProfileFeed] Error fetching events:', error)
logger.component('ProfileFeed', 'Initialization failed', { pubkey, error: (error as Error).message, retryCount: isRetry ? retryCount + 1 : 0 })
// If this is not a retry and we haven't exceeded max retries, schedule a retry
if (!isRetry && retryCount < maxRetries) {
console.log('[ProfileFeed] Scheduling retry', retryCount + 1, 'of', maxRetries)
// Use shorter delays for initial retries, then exponential backoff
const delay = retryCount === 0 ? 1000 : retryCount === 1 ? 2000 : 3000
setTimeout(() => {
setRetryCount(prev => prev + 1)
fetchPosts(true)
}, delay)
} else {
setEvents([])
} finally {
setIsLoading(false)
}
} finally {
setIsLoading(false)
setIsRetrying(false)
setIsRefreshing(false)
}
}, [pubkey, buildComprehensiveRelayList, maxRetries])
// Expose refresh function to parent component
const refresh = useCallback(() => {
setRetryCount(0)
setIsRefreshing(true)
fetchPosts(false, true) // isRetry = false, isRefresh = true
}, [fetchPosts])
useImperativeHandle(ref, () => ({
refresh
}), [refresh])
// Filter events based on search query
const filteredEvents = useMemo(() => {
if (!searchQuery.trim()) {
return events
}
fetchPosts()
}, [pubkey])
const query = searchQuery.toLowerCase()
return events.filter(event =>
event.content.toLowerCase().includes(query) ||
event.tags.some(tag =>
tag.length > 1 && tag[1]?.toLowerCase().includes(query)
)
)
}, [events, searchQuery])
// Separate effect for initial fetch only with a small delay
useEffect(() => {
if (pubkey) {
// Add a small delay to let the component fully mount and relays to be ready
const timer = setTimeout(() => {
fetchPosts()
}, 500) // 500ms delay
if (isLoading) {
return () => clearTimeout(timer)
}
}, [pubkey]) // Only depend on pubkey to avoid loops
if (isLoading || isRetrying) {
return (
<div className="space-y-2">
{isRetrying && retryCount > 0 && (
<div className="text-center py-2 text-sm text-muted-foreground">
Retrying... ({retryCount}/{maxRetries})
</div>
)}
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
@ -138,10 +209,28 @@ export default function ProfileFeed({ pubkey, topSpace }: ProfileFeedProps) { @@ -138,10 +209,28 @@ export default function ProfileFeed({ pubkey, topSpace }: ProfileFeedProps) {
)
}
if (filteredEvents.length === 0 && searchQuery.trim()) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No posts match your search</div>
</div>
)
}
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">
🔄 Refreshing posts...
</div>
)}
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredEvents.length} of {events.length} posts
</div>
)}
<div className="space-y-2">
{events.map((event) => (
{filteredEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
@ -152,4 +241,8 @@ export default function ProfileFeed({ pubkey, topSpace }: ProfileFeedProps) { @@ -152,4 +241,8 @@ export default function ProfileFeed({ pubkey, topSpace }: ProfileFeedProps) {
</div>
</div>
)
}
})
ProfileFeed.displayName = 'ProfileFeed'
export default ProfileFeed

44
src/components/Profile/index.tsx

@ -8,6 +8,8 @@ import ProfileOptions from '@/components/ProfileOptions' @@ -8,6 +8,8 @@ import ProfileOptions from '@/components/ProfileOptions'
import ProfileZapButton from '@/components/ProfileZapButton'
import PubkeyCopy from '@/components/PubkeyCopy'
import Tabs from '@/components/Tabs'
import RetroRefreshButton from '@/components/ui/RetroRefreshButton'
import ProfileSearchBar from '@/components/ui/ProfileSearchBar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
@ -18,7 +20,7 @@ import { useSecondaryPage } from '@/PageManager' @@ -18,7 +20,7 @@ import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { Link, Zap } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
import NotFound from '../NotFound'
@ -37,6 +39,12 @@ export default function Profile({ id }: { id?: string }) { @@ -37,6 +39,12 @@ export default function Profile({ id }: { id?: string }) {
const { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey } = useNostr()
const [activeTab, setActiveTab] = useState<ProfileTabValue>('posts')
const [searchQuery, setSearchQuery] = useState('')
// Refs for child components
const profileFeedRef = useRef<{ refresh: () => void }>(null)
const profileBookmarksRef = useRef<{ refresh: () => void }>(null)
const isFollowingYou = useMemo(() => {
// This will be handled by the FollowedBy component
return false
@ -47,7 +55,16 @@ export default function Profile({ id }: { id?: string }) { @@ -47,7 +55,16 @@ export default function Profile({ id }: { id?: string }) {
)
const isSelf = accountPubkey === profile?.pubkey
// Define tabs
// Refresh functions for each tab
const handleRefresh = () => {
if (activeTab === 'posts') {
profileFeedRef.current?.refresh()
} else {
profileBookmarksRef.current?.refresh()
}
}
// Define tabs with refresh buttons
const tabs = useMemo(() => [
{
value: 'posts',
@ -192,14 +209,35 @@ export default function Profile({ id }: { id?: string }) { @@ -192,14 +209,35 @@ export default function Profile({ id }: { id?: string }) {
tabs={tabs}
onTabChange={(tab) => setActiveTab(tab as ProfileTabValue)}
threshold={800}
options={
<div className="flex items-center gap-2 pr-2">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={`Search ${activeTab}...`}
className="w-64"
/>
<RetroRefreshButton
onClick={handleRefresh}
size="sm"
className="flex-shrink-0"
/>
</div>
}
/>
{activeTab === 'posts' && (
<ProfileFeed pubkey={pubkey} topSpace={0} />
<ProfileFeed
ref={profileFeedRef}
pubkey={pubkey}
topSpace={0}
searchQuery={searchQuery}
/>
)}
{(activeTab === 'pins' || activeTab === 'bookmarks' || activeTab === 'interests') && (
<ProfileBookmarksAndHashtags
ref={profileBookmarksRef}
pubkey={pubkey}
initialTab={activeTab === 'pins' ? 'pins' : activeTab === 'bookmarks' ? 'bookmarks' : 'hashtags'}
searchQuery={searchQuery}
/>
)}
</div>

74
src/components/ui/ProfileSearchBar.tsx

@ -0,0 +1,74 @@ @@ -0,0 +1,74 @@
import { Input } from '@/components/ui/input'
import { Search, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useState, useEffect } from 'react'
interface ProfileSearchBarProps {
onSearch: (query: string) => void
placeholder?: string
className?: string
disabled?: boolean
}
export default function ProfileSearchBar({
onSearch,
placeholder = "Search...",
className,
disabled = false
}: ProfileSearchBarProps) {
const [query, setQuery] = useState('')
const [isFocused, setIsFocused] = useState(false)
// Debounce search to avoid too many calls
useEffect(() => {
const timer = setTimeout(() => {
onSearch(query)
}, 300)
return () => clearTimeout(timer)
}, [query, onSearch])
const handleClear = () => {
setQuery('')
onSearch('')
}
return (
<div className={cn('relative flex items-center', className)}>
<div className="relative flex-1">
<Search
className={cn(
'absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors',
isFocused && 'text-green-500'
)}
/>
<Input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={disabled}
className={cn(
'pl-10 pr-10 h-10',
'border-2 border-muted-foreground/20 focus:border-green-500',
'bg-background text-foreground',
'transition-all duration-200',
'rounded-lg',
disabled && 'opacity-50 cursor-not-allowed'
)}
/>
{query && (
<button
onClick={handleClear}
className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground hover:text-foreground transition-colors"
disabled={disabled}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
)
}

55
src/components/ui/RetroRefreshButton.tsx

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils'
interface RetroRefreshButtonProps {
onClick: () => void
isLoading?: boolean
className?: string
size?: 'sm' | 'md' | 'lg'
}
export default function RetroRefreshButton({
onClick,
isLoading = false,
className,
size = 'md'
}: RetroRefreshButtonProps) {
const sizeClasses = {
sm: 'h-8 w-8 p-1',
md: 'h-10 w-10 p-2',
lg: 'h-12 w-12 p-3'
}
const iconSizes = {
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6'
}
return (
<Button
onClick={onClick}
disabled={isLoading}
className={cn(
'bg-background text-foreground border-2 border-green-500 hover:bg-muted hover:border-green-400',
'dark:bg-background dark:text-foreground dark:border-green-500 dark:hover:bg-muted dark:hover:border-green-400',
'transition-all duration-200 ease-in-out',
'shadow-lg hover:shadow-xl',
'rounded-lg',
'disabled:opacity-50 disabled:cursor-not-allowed',
sizeClasses[size],
className
)}
variant="outline"
>
<RefreshCw
className={cn(
'text-green-500 transition-transform duration-200',
isLoading && 'animate-spin',
iconSizes[size]
)}
/>
</Button>
)
}
Loading…
Cancel
Save