33 changed files with 950 additions and 2536 deletions
@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
|
||||
import { Event, nip19 } from 'nostr-tools' |
||||
import { useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import dayjs from 'dayjs' |
||||
import { Button } from '@/components/ui/button' |
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' |
||||
import { Copy, Check, ChevronDown, ChevronRight } from 'lucide-react' |
||||
import { toast } from 'sonner' |
||||
import logger from '@/lib/logger' |
||||
import { cn } from '@/lib/utils' |
||||
import UserAvatar from '@/components/UserAvatar' |
||||
import Username from '@/components/Username' |
||||
|
||||
export default function EventViewer({ event, className }: { event: Event; className?: string }) { |
||||
const { t } = useTranslation() |
||||
const [copiedJson, setCopiedJson] = useState(false) |
||||
const [copiedNevent, setCopiedNevent] = useState(false) |
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set(['root'])) |
||||
|
||||
const nevent = useMemo( |
||||
() => nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind }), |
||||
[event.id, event.pubkey, event.kind] |
||||
) |
||||
|
||||
const toggle = (key: string) => { |
||||
setExpanded((prev) => { |
||||
const next = new Set(prev) |
||||
if (next.has(key)) { |
||||
next.delete(key) |
||||
} else { |
||||
next.add(key) |
||||
} |
||||
return next |
||||
}) |
||||
} |
||||
|
||||
const handleCopyJson = async () => { |
||||
try { |
||||
await navigator.clipboard.writeText(JSON.stringify(event, null, 2)) |
||||
setCopiedJson(true) |
||||
toast.success(t('Copied to clipboard')) |
||||
setTimeout(() => setCopiedJson(false), 2000) |
||||
} catch (err) { |
||||
logger.error('Failed to copy event JSON', { error: err, eventId: event.id }) |
||||
toast.error(t('Failed to copy')) |
||||
} |
||||
} |
||||
|
||||
const handleCopyNevent = async () => { |
||||
try { |
||||
await navigator.clipboard.writeText(nevent) |
||||
setCopiedNevent(true) |
||||
toast.success(t('Copied to clipboard')) |
||||
setTimeout(() => setCopiedNevent(false), 2000) |
||||
} catch (err) { |
||||
logger.error('Failed to copy nevent', { error: err }) |
||||
toast.error(t('Failed to copy')) |
||||
} |
||||
} |
||||
|
||||
const renderValue = (value: unknown, key: string, depth = 0): React.ReactNode => { |
||||
if (value === null) { |
||||
return <span className="text-muted-foreground">null</span> |
||||
} |
||||
if (value === undefined) { |
||||
return <span className="text-muted-foreground">undefined</span> |
||||
} |
||||
if (typeof value === 'string') { |
||||
return <span className="text-green-600 dark:text-green-400">"{value}"</span> |
||||
} |
||||
if (typeof value === 'number' || typeof value === 'boolean') { |
||||
return <span className="text-blue-600 dark:text-blue-400">{String(value)}</span> |
||||
} |
||||
if (Array.isArray(value)) { |
||||
const isExpanded = expanded.has(key) |
||||
return ( |
||||
<div className={cn('ml-2', depth > 0 && 'border-l border-border/50 pl-2')}> |
||||
<Collapsible open={isExpanded} onOpenChange={() => toggle(key)}> |
||||
<CollapsibleTrigger className="flex items-center gap-1 text-sm hover:text-foreground"> |
||||
{isExpanded ? ( |
||||
<ChevronDown className="h-3 w-3" /> |
||||
) : ( |
||||
<ChevronRight className="h-3 w-3" /> |
||||
)} |
||||
<span className="text-muted-foreground">Array</span> |
||||
<span className="text-xs text-muted-foreground">({value.length})</span> |
||||
</CollapsibleTrigger> |
||||
<CollapsibleContent className="mt-1 ml-4"> |
||||
{value.map((item, idx) => ( |
||||
<div key={idx} className="mb-1"> |
||||
<span className="text-muted-foreground text-xs">[{idx}]</span>{' '} |
||||
{renderValue(item, `${key}[${idx}]`, depth + 1)} |
||||
</div> |
||||
))} |
||||
</CollapsibleContent> |
||||
</Collapsible> |
||||
</div> |
||||
) |
||||
} |
||||
if (typeof value === 'object') { |
||||
const isExpanded = expanded.has(key) |
||||
const entries = Object.entries(value) |
||||
return ( |
||||
<div className={cn('ml-2', depth > 0 && 'border-l border-border/50 pl-2')}> |
||||
<Collapsible open={isExpanded} onOpenChange={() => toggle(key)}> |
||||
<CollapsibleTrigger className="flex items-center gap-1 text-sm hover:text-foreground"> |
||||
{isExpanded ? ( |
||||
<ChevronDown className="h-3 w-3" /> |
||||
) : ( |
||||
<ChevronRight className="h-3 w-3" /> |
||||
)} |
||||
<span className="text-muted-foreground">Object</span> |
||||
<span className="text-xs text-muted-foreground">({entries.length} keys)</span> |
||||
</CollapsibleTrigger> |
||||
<CollapsibleContent className="mt-1 ml-4"> |
||||
{entries.map(([k, v]) => ( |
||||
<div key={k} className="mb-1"> |
||||
<span className="text-purple-600 dark:text-purple-400 font-medium">"{k}"</span>:{' '} |
||||
{renderValue(v, `${key}.${k}`, depth + 1)} |
||||
</div> |
||||
))} |
||||
</CollapsibleContent> |
||||
</Collapsible> |
||||
</div> |
||||
) |
||||
} |
||||
return <span className="text-muted-foreground">{String(value)}</span> |
||||
} |
||||
|
||||
const createdAtFormatted = dayjs(event.created_at * 1000).format('LLL') |
||||
|
||||
return ( |
||||
<div className={cn('border rounded-lg p-4 bg-muted/30', className)}> |
||||
<div className="flex items-center justify-between mb-3"> |
||||
<div className="text-sm font-semibold">Event (kind {event.kind})</div> |
||||
<Button variant="ghost" size="sm" onClick={handleCopyJson} className="h-7"> |
||||
{copiedJson ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} |
||||
</Button> |
||||
</div> |
||||
<div className="text-sm space-y-2"> |
||||
<div className="flex items-center gap-2 flex-wrap"> |
||||
<span className="text-purple-600 dark:text-purple-400 font-medium shrink-0">nevent</span> |
||||
<code className="truncate text-green-600 dark:text-green-400 text-xs">{nevent}</code> |
||||
<Button variant="ghost" size="sm" onClick={handleCopyNevent} className="h-6 w-6 p-0 shrink-0"> |
||||
{copiedNevent ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />} |
||||
</Button> |
||||
</div> |
||||
<div className="flex items-center gap-2"> |
||||
<span className="text-purple-600 dark:text-purple-400 font-medium shrink-0">pubkey</span> |
||||
<div className="flex items-center gap-1.5"> |
||||
<UserAvatar userId={event.pubkey} size="xSmall" /> |
||||
<Username userId={event.pubkey} className="font-normal" skeletonClassName="h-4" /> |
||||
</div> |
||||
</div> |
||||
<div> |
||||
<span className="text-purple-600 dark:text-purple-400 font-medium">kind</span>{' '} |
||||
{renderValue(event.kind, 'kind')} |
||||
</div> |
||||
<div> |
||||
<span className="text-purple-600 dark:text-purple-400 font-medium">created_at</span>{' '} |
||||
<span className="text-muted-foreground">{createdAtFormatted}</span> |
||||
</div> |
||||
<div className="font-mono"> |
||||
<span className="text-purple-600 dark:text-purple-400 font-medium">tags</span>{' '} |
||||
{renderValue(event.tags, 'tags')} |
||||
</div> |
||||
<div className="font-mono"> |
||||
<span className="text-purple-600 dark:text-purple-400 font-medium">content</span>{' '} |
||||
{renderValue(event.content, 'content')} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -1,58 +0,0 @@
@@ -1,58 +0,0 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { forwardRef, useMemo } from 'react' |
||||
import ProfileTimeline from './ProfileTimeline' |
||||
|
||||
const ARTICLE_KINDS = [ |
||||
kinds.LongFormArticle, |
||||
ExtendedKind.WIKI_ARTICLE_MARKDOWN, |
||||
ExtendedKind.WIKI_ARTICLE, |
||||
ExtendedKind.PUBLICATION, |
||||
kinds.Highlights |
||||
] |
||||
|
||||
interface ProfileArticlesProps { |
||||
pubkey: string |
||||
topSpace?: number |
||||
searchQuery?: string |
||||
kindFilter?: string |
||||
onEventsChange?: (events: Event[]) => void |
||||
} |
||||
|
||||
const ProfileArticles = forwardRef<{ refresh: () => void; getEvents: () => Event[] }, ProfileArticlesProps>( |
||||
({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => { |
||||
const cacheKey = useMemo(() => `${pubkey}-articles`, [pubkey]) |
||||
|
||||
const getKindLabel = (kindValue: string) => { |
||||
if (!kindValue || kindValue === 'all') return 'articles, publications, or highlights' |
||||
const kindNum = parseInt(kindValue, 10) |
||||
if (kindNum === kinds.LongFormArticle) return 'long form articles' |
||||
if (kindNum === ExtendedKind.WIKI_ARTICLE_MARKDOWN) return 'wiki articles (markdown)' |
||||
if (kindNum === ExtendedKind.WIKI_ARTICLE) return 'wiki articles (asciidoc)' |
||||
if (kindNum === ExtendedKind.PUBLICATION) return 'publications' |
||||
if (kindNum === kinds.Highlights) return 'highlights' |
||||
return 'items' |
||||
} |
||||
|
||||
return ( |
||||
<ProfileTimeline |
||||
ref={ref} |
||||
pubkey={pubkey} |
||||
topSpace={topSpace} |
||||
searchQuery={searchQuery} |
||||
kindFilter={kindFilter} |
||||
onEventsChange={onEventsChange} |
||||
kinds={ARTICLE_KINDS} |
||||
cacheKey={cacheKey} |
||||
getKindLabel={getKindLabel} |
||||
refreshLabel="Refreshing articles..." |
||||
emptyLabel="No articles found" |
||||
emptySearchLabel="No articles match your search" |
||||
/> |
||||
) |
||||
} |
||||
) |
||||
|
||||
ProfileArticles.displayName = 'ProfileArticles' |
||||
|
||||
export default ProfileArticles |
||||
@ -1,964 +0,0 @@
@@ -1,964 +0,0 @@
|
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { useCallback, useEffect, useMemo, useState, forwardRef, useImperativeHandle } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import client from '@/services/client.service' |
||||
import { queryService, replaceableEventService } from '@/services/client.service' |
||||
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' |
||||
import logger from '@/lib/logger' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import NoteCard from '../NoteCard' |
||||
import { Skeleton } from '../ui/skeleton' |
||||
|
||||
type TabValue = 'bookmarks' | 'hashtags' | 'pins' |
||||
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
type BookmarksCacheEntry = { |
||||
events: Event[] |
||||
listEvent: Event | null |
||||
lastUpdated: number |
||||
} |
||||
|
||||
type HashtagsCacheEntry = { |
||||
events: Event[] |
||||
listEvent: Event | null |
||||
lastUpdated: number |
||||
} |
||||
|
||||
type PinsCacheEntry = { |
||||
events: Event[] |
||||
listEvent: Event | null |
||||
lastUpdated: number |
||||
} |
||||
|
||||
const bookmarksCache = new Map<string, BookmarksCacheEntry>() |
||||
const hashtagsCache = new Map<string, HashtagsCacheEntry>() |
||||
const pinsCache = new Map<string, PinsCacheEntry>() |
||||
|
||||
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() |
||||
const [bookmarkEvents, setBookmarkEvents] = useState<Event[]>([]) |
||||
const [hashtagEvents, setHashtagEvents] = useState<Event[]>([]) |
||||
const [pinEvents, setPinEvents] = useState<Event[]>([]) |
||||
const [loadingBookmarks, setLoadingBookmarks] = useState(true) |
||||
const [loadingHashtags, setLoadingHashtags] = useState(true) |
||||
const [loadingPins, setLoadingPins] = useState(true) |
||||
const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null) |
||||
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 () => { |
||||
const myRelayList = myPubkey ? await client.fetchRelayList(myPubkey) : { write: [], read: [] } |
||||
const allRelays = [ |
||||
...(myRelayList.read || []), // User's inboxes (kind 10002)
|
||||
...(myRelayList.write || []), // User's outboxes (kind 10002)
|
||||
...(favoriteRelays || []), // User's favorite relays (kind 10012)
|
||||
...FAST_READ_RELAY_URLS, // Fast read relays
|
||||
...FAST_WRITE_RELAY_URLS // Fast write relays
|
||||
] |
||||
|
||||
const normalizedRelays = allRelays |
||||
.map(url => normalizeUrl(url)) |
||||
.filter((url): url is string => !!url) |
||||
|
||||
const comprehensiveRelays = Array.from(new Set(normalizedRelays)) |
||||
// Debug: Relay configuration for bookmark/interest list events
|
||||
// console.log('[ProfileBookmarksAndHashtags] Using', comprehensiveRelays.length, 'relays for bookmark/interest list events:', comprehensiveRelays)
|
||||
|
||||
return comprehensiveRelays |
||||
}, [myPubkey, favoriteRelays]) |
||||
|
||||
// Fetch bookmark list event and associated events
|
||||
const fetchBookmarks = useCallback(async (isRetry = false, isRefresh = false) => { |
||||
const cacheKey = `${pubkey}-bookmarks` |
||||
|
||||
// Check cache first
|
||||
const cachedEntry = bookmarksCache.get(cacheKey) |
||||
const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity |
||||
const isCacheFresh = cacheAge < CACHE_DURATION |
||||
|
||||
// If cache is fresh, show it immediately
|
||||
if (isCacheFresh && cachedEntry && !isRetry && !isRefresh) { |
||||
// Add cached events to client cache so they're available in note view
|
||||
cachedEntry.events.forEach(event => { |
||||
client.addEventToCache(event) |
||||
}) |
||||
setBookmarkEvents(cachedEntry.events) |
||||
setBookmarkListEvent(cachedEntry.listEvent) |
||||
setLoadingBookmarks(false) |
||||
// Still fetch in background to get updates
|
||||
} else { |
||||
if (!isRetry && !isRefresh) { |
||||
setLoadingBookmarks(true) |
||||
setRetryCountBookmarks(0) |
||||
} else if (isRetry) { |
||||
setIsRetryingBookmarks(true) |
||||
} |
||||
} |
||||
|
||||
try { |
||||
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||
|
||||
// Try to fetch bookmark list event from comprehensive relay list first
|
||||
let bookmarkList = null |
||||
try { |
||||
const bookmarkListEvents = await queryService.fetchEvents(comprehensiveRelays, { |
||||
authors: [pubkey], |
||||
kinds: [10003], // Bookmark list kind
|
||||
limit: 1 |
||||
}) |
||||
bookmarkList = bookmarkListEvents[0] || null |
||||
} catch (error) { |
||||
logger.component('ProfileBookmarksAndHashtags', 'Error fetching bookmark list from comprehensive relays, falling back to default method', { error: (error as Error).message }) |
||||
bookmarkList = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.BookmarkList) ?? null |
||||
} |
||||
|
||||
// console.log('[ProfileBookmarksAndHashtags] Bookmark list event:', bookmarkList)
|
||||
setBookmarkListEvent(bookmarkList) |
||||
|
||||
if (bookmarkList && bookmarkList.tags.length > 0) { |
||||
// Extract event IDs from bookmark list
|
||||
const eventIds = bookmarkList.tags |
||||
.filter(tag => tag[0] === 'e' && tag[1]) |
||||
.map(tag => tag[1]) |
||||
.reverse() // Reverse to show newest first
|
||||
|
||||
// Extract 'a' tags for replaceable events (publications, articles, etc.)
|
||||
const aTags = bookmarkList.tags |
||||
.filter(tag => tag[0] === 'a' && tag[1]) |
||||
.map(tag => tag[1]) |
||||
|
||||
// console.log('[ProfileBookmarksAndHashtags] Found', eventIds.length, 'bookmark event IDs and', aTags.length, 'a tags')
|
||||
|
||||
// Fetch both regular events and replaceable events
|
||||
const eventPromises: Promise<Event[]>[] = [] |
||||
|
||||
if (eventIds.length > 0) { |
||||
eventPromises.push(queryService.fetchEvents(comprehensiveRelays, { |
||||
ids: eventIds, |
||||
limit: 100 |
||||
})) |
||||
} |
||||
|
||||
if (aTags.length > 0) { |
||||
// For 'a' tags, we need to fetch replaceable events
|
||||
// Parse the coordinate to get kind, pubkey, and d tag
|
||||
const aTagFetches = aTags.map(async (aTag) => { |
||||
// aTag format: "kind:pubkey:d"
|
||||
const parts = aTag.split(':') |
||||
if (parts.length < 2) return null |
||||
const kind = parseInt(parts[0]) |
||||
const pubkey = parts[1] |
||||
const d = parts[2] || '' |
||||
|
||||
const filter: any = { |
||||
authors: [pubkey], |
||||
kinds: [kind], |
||||
limit: 1 |
||||
} |
||||
if (d) { |
||||
filter['#d'] = [d] |
||||
} |
||||
|
||||
const events = await queryService.fetchEvents(comprehensiveRelays, [filter]) |
||||
return events[0] || null |
||||
}) |
||||
|
||||
eventPromises.push(Promise.all(aTagFetches).then(events => events.filter((e): e is Event => e !== null))) |
||||
} |
||||
|
||||
if (eventPromises.length > 0) { |
||||
try { |
||||
const eventArrays = await Promise.all(eventPromises) |
||||
const events = eventArrays.flat() |
||||
logger.debug('[ProfileBookmarksAndHashtags] Fetched', events.length, 'bookmark events') |
||||
|
||||
// Add all events to client cache so they're available immediately in note view
|
||||
events.forEach(event => { |
||||
client.addEventToCache(event) |
||||
}) |
||||
|
||||
let finalEvents: Event[] |
||||
if (isRefresh) { |
||||
// For refresh, append new events and deduplicate
|
||||
// Compute final events before setting state
|
||||
const existingIds = new Set(bookmarkEvents.map(e => e.id)) |
||||
const newEvents = events.filter(event => !existingIds.has(event.id)) |
||||
finalEvents = [...newEvents, ...bookmarkEvents].sort((a, b) => b.created_at - a.created_at) |
||||
setBookmarkEvents(finalEvents) |
||||
} else { |
||||
finalEvents = events |
||||
setBookmarkEvents(events) |
||||
} |
||||
|
||||
// Update cache
|
||||
bookmarksCache.set(cacheKey, { |
||||
events: finalEvents, |
||||
listEvent: bookmarkList, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
} catch (error) { |
||||
logger.warn('[ProfileBookmarksAndHashtags] Error fetching bookmark events:', error) |
||||
setBookmarkEvents([]) |
||||
} |
||||
} else { |
||||
setBookmarkEvents([]) |
||||
// Update cache with empty result
|
||||
bookmarksCache.set(cacheKey, { |
||||
events: [], |
||||
listEvent: bookmarkList, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
} |
||||
} else { |
||||
setBookmarkEvents([]) |
||||
// Update cache with empty result
|
||||
bookmarksCache.set(cacheKey, { |
||||
events: [], |
||||
listEvent: bookmarkList, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
} |
||||
|
||||
// Reset retry count on successful fetch
|
||||
if (isRetry) { |
||||
setRetryCountBookmarks(0) |
||||
} |
||||
} catch (error) { |
||||
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) { |
||||
logger.debug('[ProfileBookmarksAndHashtags] Scheduling bookmark retry', { |
||||
attempt: retryCountBookmarks + 1, |
||||
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, retryCountBookmarks, maxRetries]) |
||||
|
||||
// Internal function to actually fetch hashtags (without cache check)
|
||||
const fetchHashtagsInternal = useCallback(async (isRetry = false, isRefresh = false, isBackgroundUpdate = false) => { |
||||
const cacheKey = `${pubkey}-hashtags` |
||||
|
||||
if (!isBackgroundUpdate) { |
||||
if (!isRetry && !isRefresh) { |
||||
setLoadingHashtags(true) |
||||
setRetryCountHashtags(0) |
||||
} else if (isRetry) { |
||||
setIsRetryingHashtags(true) |
||||
} |
||||
} |
||||
|
||||
try { |
||||
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||
|
||||
// Try to fetch interest list event from comprehensive relay list first
|
||||
let interestList = null |
||||
try { |
||||
const interestListEvents = await queryService.fetchEvents(comprehensiveRelays, { |
||||
authors: [pubkey], |
||||
kinds: [10015], // Interest list kind
|
||||
limit: 1 |
||||
}) |
||||
interestList = interestListEvents[0] || null |
||||
} catch (error) { |
||||
logger.component('ProfileBookmarksAndHashtags', 'Error fetching interest list from comprehensive relays, falling back to default method', { error: (error as Error).message }) |
||||
interestList = await replaceableEventService.fetchReplaceableEvent(pubkey, 10015) ?? null |
||||
} |
||||
|
||||
// Only update interest list event if we're not doing a background update
|
||||
if (!isBackgroundUpdate) { |
||||
setInterestListEvent(interestList) |
||||
} |
||||
|
||||
if (interestList && interestList.tags.length > 0) { |
||||
// Extract hashtags from interest list
|
||||
const hashtags = interestList.tags |
||||
.filter((tag: string[]) => tag[0] === 't' && tag[1]) |
||||
.map((tag: string[]) => tag[1]) |
||||
|
||||
// console.log('[ProfileBookmarksAndHashtags] Found', hashtags.length, 'interest hashtags:', hashtags)
|
||||
|
||||
if (hashtags.length > 0) { |
||||
try { |
||||
// Fetch recent events with these hashtags using the same comprehensive relay list
|
||||
const events = await queryService.fetchEvents(comprehensiveRelays, { |
||||
kinds: [1], // Text notes
|
||||
'#t': hashtags, |
||||
limit: 100 |
||||
}) |
||||
// console.log('[ProfileBookmarksAndHashtags] Fetched', events.length, 'hashtag events')
|
||||
|
||||
// Add all events to client cache so they're available immediately in note view
|
||||
events.forEach(event => { |
||||
client.addEventToCache(event) |
||||
}) |
||||
|
||||
let finalEvents: Event[] |
||||
if (isRefresh) { |
||||
// For refresh, append new events and deduplicate
|
||||
// Compute final events before setting state
|
||||
const existingIds = new Set(hashtagEvents.map(e => e.id)) |
||||
const newEvents = events.filter(event => !existingIds.has(event.id)) |
||||
finalEvents = [...newEvents, ...hashtagEvents].sort((a, b) => b.created_at - a.created_at) |
||||
setHashtagEvents(finalEvents) |
||||
} else if (isBackgroundUpdate) { |
||||
// For background update, merge with existing cached events
|
||||
const existingIds = new Set(hashtagEvents.map(e => e.id)) |
||||
const newEvents = events.filter(event => !existingIds.has(event.id)) |
||||
if (newEvents.length > 0) { |
||||
finalEvents = [...newEvents, ...hashtagEvents].sort((a, b) => b.created_at - a.created_at) |
||||
setHashtagEvents(finalEvents) |
||||
} else { |
||||
// No new events, keep existing ones
|
||||
finalEvents = hashtagEvents |
||||
} |
||||
} else { |
||||
finalEvents = events |
||||
setHashtagEvents(events) |
||||
} |
||||
|
||||
// Update cache only if we got events or if this is not a background update
|
||||
if (!isBackgroundUpdate || (finalEvents && finalEvents.length > 0)) { |
||||
hashtagsCache.set(cacheKey, { |
||||
events: finalEvents, |
||||
listEvent: interestList, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
} |
||||
} catch (error) { |
||||
logger.component('ProfileBookmarksAndHashtags', 'Error fetching hashtag events', { error: (error as Error).message }) |
||||
// Only clear events if this is not a background update
|
||||
if (!isBackgroundUpdate) { |
||||
setHashtagEvents([]) |
||||
} |
||||
} |
||||
} else { |
||||
// Only clear events if this is not a background update
|
||||
if (!isBackgroundUpdate) { |
||||
setHashtagEvents([]) |
||||
// Update cache with empty result
|
||||
hashtagsCache.set(cacheKey, { |
||||
events: [], |
||||
listEvent: interestList, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
} |
||||
} |
||||
} else { |
||||
// Only clear events if this is not a background update
|
||||
if (!isBackgroundUpdate) { |
||||
setHashtagEvents([]) |
||||
// Update cache with empty result
|
||||
hashtagsCache.set(cacheKey, { |
||||
events: [], |
||||
listEvent: interestList, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
} |
||||
} |
||||
|
||||
// Reset retry count on successful fetch
|
||||
if (isRetry) { |
||||
setRetryCountHashtags(0) |
||||
} |
||||
} catch (error) { |
||||
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 && !isBackgroundUpdate) { |
||||
logger.debug('[ProfileBookmarksAndHashtags] Scheduling hashtag retry', { |
||||
attempt: retryCountHashtags + 1, |
||||
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 if (!isBackgroundUpdate) { |
||||
// Only clear events if this is not a background update
|
||||
setHashtagEvents([]) |
||||
} |
||||
} finally { |
||||
// Only update loading state if this is not a background update
|
||||
if (!isBackgroundUpdate) { |
||||
setLoadingHashtags(false) |
||||
setIsRetryingHashtags(false) |
||||
if (isRefresh) { |
||||
setIsRefreshing(false) |
||||
} |
||||
} |
||||
} |
||||
}, [pubkey, buildComprehensiveRelayList, retryCountHashtags, maxRetries, hashtagEvents]) |
||||
|
||||
// Main fetch function with cache check
|
||||
const fetchHashtags = useCallback(async (isRetry = false, isRefresh = false) => { |
||||
const cacheKey = `${pubkey}-hashtags` |
||||
|
||||
// Check cache first
|
||||
const cachedEntry = hashtagsCache.get(cacheKey) |
||||
const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity |
||||
const isCacheFresh = cacheAge < CACHE_DURATION |
||||
|
||||
// Track if we're doing a background update (cache is fresh, just checking for new events)
|
||||
const isBackgroundUpdate = isCacheFresh && cachedEntry && !isRetry && !isRefresh |
||||
|
||||
// If cache is fresh, show it immediately and defer background fetch
|
||||
if (isBackgroundUpdate) { |
||||
// Add cached events to client cache so they're available in note view
|
||||
cachedEntry.events.forEach(event => { |
||||
client.addEventToCache(event) |
||||
}) |
||||
setHashtagEvents(cachedEntry.events) |
||||
setInterestListEvent(cachedEntry.listEvent) |
||||
setLoadingHashtags(false) |
||||
|
||||
// Defer background fetch to next tick to avoid blocking UI
|
||||
setTimeout(() => { |
||||
// Run background fetch asynchronously without blocking
|
||||
fetchHashtagsInternal(false, false, true).catch(() => { |
||||
// Silently fail background updates
|
||||
}) |
||||
}, 100) // Small delay to let UI render first
|
||||
return // Exit early, background fetch will run asynchronously
|
||||
} |
||||
|
||||
// Not a background update, proceed with normal fetch
|
||||
return fetchHashtagsInternal(isRetry, isRefresh, false) |
||||
}, [pubkey, fetchHashtagsInternal]) |
||||
|
||||
// Fetch pin list event and associated events
|
||||
const fetchPins = useCallback(async (isRetry = false, isRefresh = false) => { |
||||
const cacheKey = `${pubkey}-pins` |
||||
|
||||
// Check cache first
|
||||
const cachedEntry = pinsCache.get(cacheKey) |
||||
const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity |
||||
const isCacheFresh = cacheAge < CACHE_DURATION |
||||
|
||||
// If cache is fresh, show it immediately
|
||||
if (isCacheFresh && cachedEntry && !isRetry && !isRefresh) { |
||||
// Add cached events to client cache so they're available in note view
|
||||
cachedEntry.events.forEach(event => { |
||||
client.addEventToCache(event) |
||||
}) |
||||
setPinEvents(cachedEntry.events) |
||||
setPinListEvent(cachedEntry.listEvent) |
||||
setLoadingPins(false) |
||||
// Still fetch in background to get updates
|
||||
} else { |
||||
if (!isRetry && !isRefresh) { |
||||
setLoadingPins(true) |
||||
setRetryCountPins(0) |
||||
} else if (isRetry) { |
||||
setIsRetryingPins(true) |
||||
} |
||||
} |
||||
|
||||
try { |
||||
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||
|
||||
logger.component('ProfileBookmarksAndHashtags', 'Fetching pins for pubkey', { pubkey, relayCount: comprehensiveRelays.length }) |
||||
|
||||
// Try to fetch pin list event from comprehensive relay list first
|
||||
let pinList = null |
||||
try { |
||||
const pinListEvents = await queryService.fetchEvents(comprehensiveRelays, { |
||||
authors: [pubkey], |
||||
kinds: [10001], // Pin list kind
|
||||
limit: 1 |
||||
}) |
||||
pinList = pinListEvents[0] || null |
||||
logger.component('ProfileBookmarksAndHashtags', 'Found pin list event', { found: !!pinList }) |
||||
} catch (error) { |
||||
logger.component('ProfileBookmarksAndHashtags', 'Error fetching pin list from comprehensive relays, falling back to default method', { error: (error as Error).message }) |
||||
pinList = await replaceableEventService.fetchReplaceableEvent(pubkey, 10001) ?? null |
||||
logger.component('ProfileBookmarksAndHashtags', 'Fallback pin list event', { found: !!pinList }) |
||||
} |
||||
|
||||
// console.log('[ProfileBookmarksAndHashtags] Pin list event:', pinList)
|
||||
setPinListEvent(pinList) |
||||
|
||||
if (pinList && pinList.tags.length > 0) { |
||||
// Extract event IDs from pin list
|
||||
const eventIds = pinList.tags |
||||
.filter(tag => tag[0] === 'e' && tag[1]) |
||||
.map(tag => tag[1]) |
||||
.reverse() // Reverse to show newest first
|
||||
|
||||
// Extract 'a' tags for replaceable events (publications, articles, etc.)
|
||||
const aTags = pinList.tags |
||||
.filter(tag => tag[0] === 'a' && tag[1]) |
||||
.map(tag => tag[1]) |
||||
|
||||
// console.log('[ProfileBookmarksAndHashtags] Found', eventIds.length, 'pin event IDs and', aTags.length, 'a tags')
|
||||
|
||||
// Fetch both regular events and replaceable events
|
||||
const eventPromises: Promise<Event[]>[] = [] |
||||
|
||||
if (eventIds.length > 0) { |
||||
eventPromises.push(queryService.fetchEvents(comprehensiveRelays, { |
||||
ids: eventIds, |
||||
limit: 100 |
||||
})) |
||||
} |
||||
|
||||
if (aTags.length > 0) { |
||||
// For 'a' tags, we need to fetch replaceable events
|
||||
// Parse the coordinate to get kind, pubkey, and d tag
|
||||
const aTagFetches = aTags.map(async (aTag) => { |
||||
// aTag format: "kind:pubkey:d"
|
||||
const parts = aTag.split(':') |
||||
if (parts.length < 2) return null |
||||
const kind = parseInt(parts[0]) |
||||
const pubkey = parts[1] |
||||
const d = parts[2] || '' |
||||
|
||||
const filter: any = { |
||||
authors: [pubkey], |
||||
kinds: [kind], |
||||
limit: 1 |
||||
} |
||||
if (d) { |
||||
filter['#d'] = [d] |
||||
} |
||||
|
||||
const events = await queryService.fetchEvents(comprehensiveRelays, [filter]) |
||||
return events[0] || null |
||||
}) |
||||
|
||||
eventPromises.push(Promise.all(aTagFetches).then(events => events.filter((e): e is Event => e !== null))) |
||||
} |
||||
|
||||
if (eventPromises.length > 0) { |
||||
try { |
||||
const eventArrays = await Promise.all(eventPromises) |
||||
const events = eventArrays.flat() |
||||
logger.debug('[ProfileBookmarksAndHashtags] Fetched', events.length, 'pin events') |
||||
|
||||
// Add all events to client cache so they're available immediately in note view
|
||||
events.forEach(event => { |
||||
client.addEventToCache(event) |
||||
}) |
||||
|
||||
let finalEvents: Event[] |
||||
if (isRefresh) { |
||||
// For refresh, append new events and deduplicate
|
||||
// Compute final events before setting state
|
||||
const existingIds = new Set(pinEvents.map(e => e.id)) |
||||
const newEvents = events.filter(event => !existingIds.has(event.id)) |
||||
finalEvents = [...newEvents, ...pinEvents].sort((a, b) => b.created_at - a.created_at) |
||||
setPinEvents(finalEvents) |
||||
} else { |
||||
finalEvents = events |
||||
setPinEvents(events) |
||||
} |
||||
|
||||
// Update cache
|
||||
pinsCache.set(cacheKey, { |
||||
events: finalEvents, |
||||
listEvent: pinList, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
} catch (error) { |
||||
logger.warn('[ProfileBookmarksAndHashtags] Error fetching pin events:', error) |
||||
setPinEvents([]) |
||||
} |
||||
} else { |
||||
setPinEvents([]) |
||||
// Update cache with empty result
|
||||
pinsCache.set(cacheKey, { |
||||
events: [], |
||||
listEvent: pinList, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
} |
||||
} else { |
||||
setPinEvents([]) |
||||
// Update cache with empty result
|
||||
pinsCache.set(cacheKey, { |
||||
events: [], |
||||
listEvent: pinList, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
} |
||||
|
||||
// Reset retry count on successful fetch
|
||||
if (isRetry) { |
||||
setRetryCountPins(0) |
||||
} |
||||
} catch (error) { |
||||
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) { |
||||
logger.debug('[ProfileBookmarksAndHashtags] Scheduling pin retry', { |
||||
attempt: retryCountPins + 1, |
||||
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, retryCountPins, maxRetries]) |
||||
|
||||
|
||||
// Expose refresh function to parent component
|
||||
const refresh = useCallback(() => { |
||||
// Clear all caches on refresh
|
||||
bookmarksCache.delete(`${pubkey}-bookmarks`) |
||||
hashtagsCache.delete(`${pubkey}-hashtags`) |
||||
pinsCache.delete(`${pubkey}-pins`) |
||||
|
||||
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
|
||||
}, [pubkey, fetchBookmarks, fetchHashtags, fetchPins]) |
||||
|
||||
useImperativeHandle(ref, () => ({ |
||||
refresh |
||||
}), [refresh]) |
||||
|
||||
// Fetch data when component mounts or pubkey changes - delay slightly to avoid race conditions
|
||||
useEffect(() => { |
||||
if (pubkey) { |
||||
// Small delay to stagger initial fetches and allow relay list cache to populate
|
||||
const timeoutId = setTimeout(() => { |
||||
fetchBookmarks() |
||||
fetchHashtags() |
||||
fetchPins() |
||||
}, 200) // 200ms delay (longest since this component does 3 fetches) to allow previous fetches to populate cache
|
||||
return () => clearTimeout(timeoutId) |
||||
} |
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pubkey]) // Only depend on pubkey - fetch functions are stable from useCallback
|
||||
|
||||
// Check if the requested tab has content
|
||||
const hasContent = useMemo(() => { |
||||
switch (initialTab) { |
||||
case 'pins': |
||||
return pinListEvent || loadingPins |
||||
case 'bookmarks': |
||||
return bookmarkListEvent || loadingBookmarks |
||||
case 'hashtags': |
||||
return interestListEvent || loadingHashtags |
||||
default: |
||||
return false |
||||
} |
||||
}, [initialTab, pinListEvent, bookmarkListEvent, interestListEvent, loadingPins, loadingBookmarks, loadingHashtags]) |
||||
|
||||
// Render loading state for the specific tab
|
||||
const isLoading = useMemo(() => { |
||||
switch (initialTab) { |
||||
case 'pins': |
||||
return loadingPins || isRetryingPins |
||||
case 'bookmarks': |
||||
return loadingBookmarks || isRetryingBookmarks |
||||
case 'hashtags': |
||||
return loadingHashtags || isRetryingHashtags |
||||
default: |
||||
return false |
||||
} |
||||
}, [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" /> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
// If no content available for this tab, don't render anything
|
||||
if (!hasContent) { |
||||
return null |
||||
} |
||||
|
||||
// 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"> |
||||
{Array.from({ length: 3 }).map((_, i) => ( |
||||
<Skeleton key={i} className="h-32 w-full" /> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (pinEvents.length === 0) { |
||||
return ( |
||||
<div className="text-center py-8 text-muted-foreground"> |
||||
{t('No pins found')} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
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"> |
||||
{filteredPinEvents.map((event) => ( |
||||
<NoteCard |
||||
key={event.id} |
||||
className="w-full" |
||||
event={event} |
||||
filterMutedNotes={false} |
||||
/> |
||||
))} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
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"> |
||||
{Array.from({ length: 3 }).map((_, i) => ( |
||||
<Skeleton key={i} className="h-32 w-full" /> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (bookmarkEvents.length === 0) { |
||||
return ( |
||||
<div className="text-center py-8 text-muted-foreground"> |
||||
{t('No bookmarks found')} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
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"> |
||||
{filteredBookmarkEvents.map((event) => ( |
||||
<NoteCard |
||||
key={event.id} |
||||
className="w-full" |
||||
event={event} |
||||
filterMutedNotes={false} |
||||
/> |
||||
))} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
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"> |
||||
{Array.from({ length: 3 }).map((_, i) => ( |
||||
<Skeleton key={i} className="h-32 w-full" /> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (hashtagEvents.length === 0) { |
||||
return ( |
||||
<div className="text-center py-8 text-muted-foreground"> |
||||
{t('No interest-related content found')} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
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"> |
||||
{filteredHashtagEvents.map((event) => ( |
||||
<NoteCard |
||||
key={event.id} |
||||
className="w-full" |
||||
event={event} |
||||
filterMutedNotes={false} |
||||
/> |
||||
))} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return null |
||||
} |
||||
|
||||
return renderContent() |
||||
}) |
||||
|
||||
ProfileBookmarksAndHashtags.displayName = 'ProfileBookmarksAndHashtags' |
||||
|
||||
export default ProfileBookmarksAndHashtags |
||||
@ -0,0 +1,195 @@
@@ -0,0 +1,195 @@
|
||||
import NoteCard from '@/components/NoteCard' |
||||
import ProfileSearchBar from '@/components/ui/ProfileSearchBar' |
||||
import RetroRefreshButton from '@/components/ui/RetroRefreshButton' |
||||
import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants' |
||||
import { getZapInfoFromEvent } from '@/lib/event-metadata' |
||||
import { useProfilePins } from '@/hooks/useProfilePins' |
||||
import { useProfileTimeline } from '@/hooks/useProfileTimeline' |
||||
import { useZap } from '@/providers/ZapProvider' |
||||
import { Event } from 'nostr-tools' |
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
|
||||
const INITIAL_SHOW_COUNT = 25 |
||||
const LOAD_MORE_COUNT = 25 |
||||
|
||||
const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { |
||||
const { t } = useTranslation() |
||||
const { zapReplyThreshold } = useZap() |
||||
const [searchQuery, setSearchQuery] = useState('') |
||||
const [isRefreshing, setIsRefreshing] = useState(false) |
||||
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) |
||||
const bottomRef = useRef<HTMLDivElement>(null) |
||||
|
||||
const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey) |
||||
|
||||
const filterPredicate = useCallback( |
||||
(event: Event) => { |
||||
if (event.kind === ExtendedKind.ZAP_RECEIPT) { |
||||
const zapInfo = getZapInfoFromEvent(event) |
||||
if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) { |
||||
return false |
||||
} |
||||
} |
||||
return true |
||||
}, |
||||
[zapReplyThreshold] |
||||
) |
||||
|
||||
const cacheKey = useMemo(() => `${pubkey}-profile-unified-${zapReplyThreshold}`, [pubkey, zapReplyThreshold]) |
||||
|
||||
const { events: timelineEvents, isLoading: loadingTimeline, refresh: refreshTimeline } = useProfileTimeline({ |
||||
pubkey, |
||||
cacheKey, |
||||
kinds: PROFILE_FEED_KINDS, |
||||
limit: 200, |
||||
filterPredicate |
||||
}) |
||||
|
||||
const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents]) |
||||
|
||||
const restTimeline = useMemo( |
||||
() => timelineEvents.filter((e) => !pinIds.has(e.id)), |
||||
[timelineEvents, pinIds] |
||||
) |
||||
|
||||
const applySearch = useCallback( |
||||
(events: Event[]) => { |
||||
const q = searchQuery.trim().toLowerCase() |
||||
if (!q) return events |
||||
return events.filter((event) => { |
||||
if (event.content.toLowerCase().includes(q)) return true |
||||
return event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(q)) |
||||
}) |
||||
}, |
||||
[searchQuery] |
||||
) |
||||
|
||||
const filteredPins = useMemo(() => applySearch(pinEvents), [pinEvents, applySearch]) |
||||
const filteredRest = useMemo(() => applySearch(restTimeline), [restTimeline, applySearch]) |
||||
|
||||
const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest]) |
||||
|
||||
useEffect(() => { |
||||
setShowCount(INITIAL_SHOW_COUNT) |
||||
}, [searchQuery, pubkey]) |
||||
|
||||
useEffect(() => { |
||||
if (!loadingPins && !loadingTimeline) { |
||||
setIsRefreshing(false) |
||||
} |
||||
}, [loadingPins, loadingTimeline]) |
||||
|
||||
const refreshAll = useCallback(() => { |
||||
setIsRefreshing(true) |
||||
refreshPins() |
||||
refreshTimeline() |
||||
}, [refreshPins, refreshTimeline]) |
||||
|
||||
useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) |
||||
|
||||
const displayedEvents = useMemo( |
||||
() => mergedDisplay.slice(0, showCount), |
||||
[mergedDisplay, showCount] |
||||
) |
||||
|
||||
useEffect(() => { |
||||
if (!bottomRef.current || displayedEvents.length >= mergedDisplay.length) return |
||||
const observer = new IntersectionObserver( |
||||
(entries) => { |
||||
if (entries[0]?.isIntersecting && displayedEvents.length < mergedDisplay.length) { |
||||
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, mergedDisplay.length)) |
||||
} |
||||
}, |
||||
{ threshold: 0.1 } |
||||
) |
||||
observer.observe(bottomRef.current) |
||||
return () => observer.disconnect() |
||||
}, [displayedEvents.length, mergedDisplay.length]) |
||||
|
||||
const loading = (loadingPins || loadingTimeline) && mergedDisplay.length === 0 |
||||
|
||||
if (loading) { |
||||
return ( |
||||
<div className="mt-4 space-y-2 px-1"> |
||||
<div className="flex flex-wrap items-center gap-2 px-2"> |
||||
<ProfileSearchBar |
||||
onSearch={setSearchQuery} |
||||
placeholder={t('Search posts...')} |
||||
className="w-64 max-w-full" |
||||
/> |
||||
<RetroRefreshButton onClick={refreshAll} size="sm" className="flex-shrink-0" /> |
||||
</div> |
||||
<div className="space-y-2"> |
||||
{Array.from({ length: 4 }).map((_, i) => ( |
||||
<Skeleton key={i} className="h-32 w-full" /> |
||||
))} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (!mergedDisplay.length && !loadingPins && !loadingTimeline) { |
||||
return ( |
||||
<div className="mt-4 px-2"> |
||||
<div className="flex flex-wrap items-center gap-2 mb-4"> |
||||
<ProfileSearchBar |
||||
onSearch={setSearchQuery} |
||||
placeholder={t('Search posts...')} |
||||
className="w-64 max-w-full" |
||||
/> |
||||
<RetroRefreshButton onClick={refreshAll} size="sm" className="flex-shrink-0" /> |
||||
</div> |
||||
<div className="flex justify-center py-8 text-sm text-muted-foreground"> |
||||
{searchQuery.trim() ? t('No posts match your search') : t('No posts found')} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className="mt-4"> |
||||
<div className="flex flex-wrap items-center gap-2 px-2 mb-2"> |
||||
<ProfileSearchBar |
||||
onSearch={setSearchQuery} |
||||
placeholder={t('Search posts...')} |
||||
className="w-64 max-w-full" |
||||
/> |
||||
<RetroRefreshButton onClick={refreshAll} size="sm" className="flex-shrink-0" /> |
||||
</div> |
||||
{isRefreshing && ( |
||||
<div className="px-4 py-2 text-center text-sm text-green-500">🔄 {t('Refreshing posts...')}</div> |
||||
)} |
||||
{searchQuery.trim() && ( |
||||
<div className="px-4 py-2 text-sm text-muted-foreground"> |
||||
{t('Showing {{filtered}} of {{total}} items', { |
||||
filtered: displayedEvents.length, |
||||
total: mergedDisplay.length |
||||
})} |
||||
</div> |
||||
)} |
||||
<div className="space-y-2"> |
||||
{displayedEvents.map((event, index) => ( |
||||
<div key={event.id}> |
||||
{index === filteredPins.length && filteredPins.length > 0 && filteredRest.length > 0 && ( |
||||
<div className="text-xs text-muted-foreground px-2 py-1 border-t border-border/60 mt-2 pt-2"> |
||||
{t('Posts')} |
||||
</div> |
||||
)} |
||||
<NoteCard className="w-full" event={event} filterMutedNotes={false} /> |
||||
</div> |
||||
))} |
||||
</div> |
||||
{displayedEvents.length < mergedDisplay.length && ( |
||||
<div ref={bottomRef} className="flex h-10 items-center justify-center"> |
||||
<div className="text-sm text-muted-foreground">{t('Loading more...')}</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
}) |
||||
|
||||
ProfileFeedWithPins.displayName = 'ProfileFeedWithPins' |
||||
|
||||
export default ProfileFeedWithPins |
||||
@ -1,315 +0,0 @@
@@ -1,315 +0,0 @@
|
||||
import NoteCard from '@/components/NoteCard' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { ExtendedKind } from '@/constants' |
||||
import { getZapInfoFromEvent } from '@/lib/event-metadata' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef, useCallback } from 'react' |
||||
import { queryService } from '@/services/client.service' |
||||
import { FAST_READ_RELAY_URLS } from '@/constants' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import { useZap } from '@/providers/ZapProvider' |
||||
import logger from '@/lib/logger' |
||||
|
||||
const INITIAL_SHOW_COUNT = 25 |
||||
const LOAD_MORE_COUNT = 25 |
||||
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
type InteractionsCacheEntry = { |
||||
events: Event[] |
||||
lastUpdated: number |
||||
} |
||||
|
||||
const interactionsCache = new Map<string, InteractionsCacheEntry>() |
||||
|
||||
interface ProfileInteractionsProps { |
||||
accountPubkey: string |
||||
profilePubkey: string |
||||
topSpace?: number |
||||
searchQuery?: string |
||||
onEventsChange?: (events: Event[]) => void |
||||
} |
||||
|
||||
const ProfileInteractions = forwardRef< |
||||
{ refresh: () => void; getEvents?: () => Event[] }, |
||||
ProfileInteractionsProps |
||||
>( |
||||
( |
||||
{ |
||||
accountPubkey, |
||||
profilePubkey, |
||||
topSpace, |
||||
searchQuery = '', |
||||
onEventsChange |
||||
}, |
||||
ref |
||||
) => { |
||||
const { zapReplyThreshold } = useZap() |
||||
const [isRefreshing, setIsRefreshing] = useState(false) |
||||
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) |
||||
const [events, setEvents] = useState<Event[]>([]) |
||||
const [isLoading, setIsLoading] = useState(true) |
||||
const [refreshToken, setRefreshToken] = useState(0) |
||||
const bottomRef = useRef<HTMLDivElement>(null) |
||||
|
||||
// Create cache key based on account and profile pubkeys
|
||||
const cacheKey = useMemo(() => `${accountPubkey}-${profilePubkey}-${zapReplyThreshold}`, [accountPubkey, profilePubkey, zapReplyThreshold]) |
||||
|
||||
const fetchInteractions = useCallback(async () => { |
||||
// Check cache first
|
||||
const cachedEntry = interactionsCache.get(cacheKey) |
||||
const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity |
||||
const isCacheFresh = cacheAge < CACHE_DURATION |
||||
|
||||
// If cache is fresh, show it immediately
|
||||
if (isCacheFresh && cachedEntry) { |
||||
setEvents(cachedEntry.events) |
||||
setIsLoading(false) |
||||
// Still fetch in background to get updates
|
||||
} else { |
||||
setIsLoading(!cachedEntry) |
||||
} |
||||
try { |
||||
const relayUrls = FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) |
||||
|
||||
// Fetch events where accountPubkey interacted with profilePubkey
|
||||
// 1. Replies: accountPubkey replied to profilePubkey's notes
|
||||
// 2. Zaps: accountPubkey zapped profilePubkey
|
||||
// 3. Mentions: accountPubkey mentioned profilePubkey
|
||||
// 4. Replies to accountPubkey: profilePubkey replied to accountPubkey's notes
|
||||
|
||||
const filters: any[] = [] |
||||
|
||||
// Get profilePubkey's notes to find replies to them
|
||||
const profileNotes = await queryService.fetchEvents(relayUrls, [{ |
||||
authors: [profilePubkey], |
||||
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.DISCUSSION], |
||||
limit: 100 |
||||
}]) |
||||
|
||||
const profileNoteIds = profileNotes.map(e => e.id) |
||||
|
||||
// Replies from accountPubkey to profilePubkey's notes
|
||||
if (profileNoteIds.length > 0) { |
||||
filters.push({ |
||||
authors: [accountPubkey], |
||||
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], |
||||
'#e': profileNoteIds, |
||||
limit: 100 |
||||
}) |
||||
} |
||||
|
||||
// Zaps from accountPubkey to profilePubkey
|
||||
filters.push({ |
||||
authors: [accountPubkey], |
||||
kinds: [kinds.Zap], |
||||
'#p': [profilePubkey], |
||||
limit: 100 |
||||
}) |
||||
|
||||
// Mentions: accountPubkey mentioned profilePubkey
|
||||
filters.push({ |
||||
authors: [accountPubkey], |
||||
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.PUBLIC_MESSAGE], |
||||
'#p': [profilePubkey], |
||||
limit: 100 |
||||
}) |
||||
|
||||
// Get accountPubkey's notes to find replies from profilePubkey
|
||||
const accountNotes = await queryService.fetchEvents(relayUrls, [{ |
||||
authors: [accountPubkey], |
||||
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.DISCUSSION], |
||||
limit: 100 |
||||
}]) |
||||
|
||||
const accountNoteIds = accountNotes.map(e => e.id) |
||||
|
||||
// Replies from profilePubkey to accountPubkey's notes
|
||||
if (accountNoteIds.length > 0) { |
||||
filters.push({ |
||||
authors: [profilePubkey], |
||||
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], |
||||
'#e': accountNoteIds, |
||||
limit: 100 |
||||
}) |
||||
} |
||||
|
||||
// Zaps from profilePubkey to accountPubkey
|
||||
filters.push({ |
||||
authors: [profilePubkey], |
||||
kinds: [kinds.Zap], |
||||
'#p': [accountPubkey], |
||||
limit: 100 |
||||
}) |
||||
|
||||
// Mentions: profilePubkey mentioned accountPubkey
|
||||
filters.push({ |
||||
authors: [profilePubkey], |
||||
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.PUBLIC_MESSAGE], |
||||
'#p': [accountPubkey], |
||||
limit: 100 |
||||
}) |
||||
|
||||
const allEvents = await queryService.fetchEvents(relayUrls, filters) |
||||
|
||||
// Deduplicate and filter
|
||||
const seenIds = new Set<string>() |
||||
const uniqueEvents = allEvents.filter(event => { |
||||
if (seenIds.has(event.id)) return false |
||||
seenIds.add(event.id) |
||||
|
||||
// Filter zap receipts below threshold
|
||||
if (event.kind === ExtendedKind.ZAP_RECEIPT) { |
||||
const zapInfo = getZapInfoFromEvent(event) |
||||
if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
return true |
||||
}) |
||||
|
||||
// Sort by created_at descending
|
||||
uniqueEvents.sort((a, b) => b.created_at - a.created_at) |
||||
|
||||
// Update cache
|
||||
interactionsCache.set(cacheKey, { |
||||
events: uniqueEvents, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
|
||||
setEvents(uniqueEvents) |
||||
} catch (error) { |
||||
logger.error('Failed to fetch interactions', error) |
||||
setEvents([]) |
||||
} finally { |
||||
setIsLoading(false) |
||||
setIsRefreshing(false) |
||||
} |
||||
}, [accountPubkey, profilePubkey, zapReplyThreshold, cacheKey]) |
||||
|
||||
useEffect(() => { |
||||
if (!accountPubkey || !profilePubkey) return |
||||
fetchInteractions() |
||||
}, [accountPubkey, profilePubkey, refreshToken, fetchInteractions]) |
||||
|
||||
useEffect(() => { |
||||
onEventsChange?.(events) |
||||
}, [events, onEventsChange]) |
||||
|
||||
useImperativeHandle( |
||||
ref, |
||||
() => ({ |
||||
refresh: () => { |
||||
setIsRefreshing(true) |
||||
// Clear cache on refresh
|
||||
interactionsCache.delete(cacheKey) |
||||
setRefreshToken((prev) => prev + 1) |
||||
}, |
||||
getEvents: () => events |
||||
}), |
||||
[events] |
||||
) |
||||
|
||||
const filteredEvents = useMemo(() => { |
||||
if (!searchQuery.trim()) { |
||||
return events |
||||
} |
||||
const query = searchQuery.toLowerCase().trim() |
||||
return events.filter((event) => { |
||||
const contentLower = event.content.toLowerCase() |
||||
if (contentLower.includes(query)) return true |
||||
return event.tags.some((tag) => { |
||||
if (tag.length <= 1) return false |
||||
const tagValue = tag[1] |
||||
return tagValue && tagValue.toLowerCase().includes(query) |
||||
}) |
||||
}) |
||||
}, [events, searchQuery]) |
||||
|
||||
// Reset showCount when filters change
|
||||
useEffect(() => { |
||||
setShowCount(INITIAL_SHOW_COUNT) |
||||
}, [searchQuery]) |
||||
|
||||
// Pagination: slice to showCount for display
|
||||
const displayedEvents = useMemo(() => { |
||||
return filteredEvents.slice(0, showCount) |
||||
}, [filteredEvents, showCount]) |
||||
|
||||
// IntersectionObserver for infinite scroll
|
||||
useEffect(() => { |
||||
if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return |
||||
|
||||
const observer = new IntersectionObserver( |
||||
(entries) => { |
||||
if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) { |
||||
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length)) |
||||
} |
||||
}, |
||||
{ threshold: 0.1 } |
||||
) |
||||
|
||||
observer.observe(bottomRef.current) |
||||
|
||||
return () => { |
||||
observer.disconnect() |
||||
} |
||||
}, [displayedEvents.length, filteredEvents.length]) |
||||
|
||||
if (!accountPubkey || !profilePubkey) { |
||||
return ( |
||||
<div className="flex justify-center items-center py-8"> |
||||
<div className="text-sm text-muted-foreground">No interactions to show</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (isLoading && events.length === 0) { |
||||
return ( |
||||
<div className="space-y-2"> |
||||
{Array.from({ length: 3 }).map((_, i) => ( |
||||
<Skeleton key={i} className="h-32 w-full" /> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (!filteredEvents.length && !isLoading) { |
||||
return ( |
||||
<div className="flex justify-center items-center py-8"> |
||||
<div className="text-sm text-muted-foreground"> |
||||
{searchQuery.trim() ? 'No interactions match your search' : 'No interactions found'} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div style={{ marginTop: topSpace || 0 }}> |
||||
{isRefreshing && ( |
||||
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing interactions...</div> |
||||
)} |
||||
{searchQuery.trim() && ( |
||||
<div className="px-4 py-2 text-sm text-muted-foreground"> |
||||
Showing {displayedEvents.length} of {filteredEvents.length} interactions |
||||
</div> |
||||
)} |
||||
<div className="space-y-2"> |
||||
{displayedEvents.map((event) => ( |
||||
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} /> |
||||
))} |
||||
</div> |
||||
{displayedEvents.length < filteredEvents.length && ( |
||||
<div ref={bottomRef} className="h-10 flex items-center justify-center"> |
||||
<div className="text-sm text-muted-foreground">Loading more...</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
) |
||||
|
||||
ProfileInteractions.displayName = 'ProfileInteractions' |
||||
|
||||
export default ProfileInteractions |
||||
|
||||
@ -1,57 +0,0 @@
@@ -1,57 +0,0 @@
|
||||
import { Event } from 'nostr-tools' |
||||
import { forwardRef, useMemo } from 'react' |
||||
import { ExtendedKind } from '@/constants' |
||||
import ProfileTimeline from './ProfileTimeline' |
||||
|
||||
const MEDIA_KIND_LIST = [ |
||||
ExtendedKind.PICTURE, |
||||
ExtendedKind.VIDEO, |
||||
ExtendedKind.SHORT_VIDEO |
||||
] |
||||
|
||||
interface ProfileMediaProps { |
||||
pubkey: string |
||||
topSpace?: number |
||||
searchQuery?: string |
||||
kindFilter?: string |
||||
onEventsChange?: (events: Event[]) => void |
||||
} |
||||
|
||||
const ProfileMedia = forwardRef<{ refresh: () => void; getEvents: () => Event[] }, ProfileMediaProps>( |
||||
({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => { |
||||
const cacheKey = useMemo(() => `${pubkey}-media`, [pubkey]) |
||||
|
||||
const getKindLabel = (kindValue: string) => { |
||||
if (!kindValue || kindValue === 'all') return 'media items' |
||||
const kindNum = parseInt(kindValue, 10) |
||||
if (kindNum === ExtendedKind.PICTURE) return 'photos' |
||||
if (kindNum === ExtendedKind.VIDEO) return 'videos' |
||||
if (kindNum === ExtendedKind.SHORT_VIDEO) return 'short videos' |
||||
if (kindNum === ExtendedKind.VOICE) return 'voice posts' |
||||
if (kindNum === ExtendedKind.VOICE_COMMENT) return 'voice comments' |
||||
return 'media' |
||||
} |
||||
|
||||
return ( |
||||
<ProfileTimeline |
||||
ref={ref} |
||||
pubkey={pubkey} |
||||
topSpace={topSpace} |
||||
searchQuery={searchQuery} |
||||
kindFilter={kindFilter} |
||||
onEventsChange={onEventsChange} |
||||
kinds={MEDIA_KIND_LIST} |
||||
cacheKey={cacheKey} |
||||
getKindLabel={getKindLabel} |
||||
refreshLabel="Refreshing media..." |
||||
emptyLabel="No media found" |
||||
emptySearchLabel="No media match your search" |
||||
/> |
||||
) |
||||
} |
||||
) |
||||
|
||||
ProfileMedia.displayName = 'ProfileMedia' |
||||
|
||||
export default ProfileMedia |
||||
|
||||
@ -1,188 +0,0 @@
@@ -1,188 +0,0 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { Event } from 'nostr-tools' |
||||
import { forwardRef, useMemo, useEffect, useImperativeHandle, useState, useRef } from 'react' |
||||
import { useProfileNotesTimeline } from '@/hooks/useProfileNotesTimeline' |
||||
import NoteCard from '@/components/NoteCard' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
|
||||
const INITIAL_SHOW_COUNT = 25 |
||||
const LOAD_MORE_COUNT = 25 |
||||
|
||||
const NOTES_KIND_LIST = [ |
||||
ExtendedKind.PUBLICATION_CONTENT, // 30041
|
||||
ExtendedKind.CITATION_INTERNAL, // 30
|
||||
ExtendedKind.CITATION_EXTERNAL, // 31
|
||||
ExtendedKind.CITATION_HARDCOPY, // 32
|
||||
ExtendedKind.CITATION_PROMPT // 33
|
||||
] |
||||
|
||||
interface ProfileNotesProps { |
||||
pubkey: string |
||||
topSpace?: number |
||||
searchQuery?: string |
||||
kindFilter?: string |
||||
onEventsChange?: (events: Event[]) => void |
||||
} |
||||
|
||||
const ProfileNotes = forwardRef<{ refresh: () => void; getEvents?: () => Event[] }, ProfileNotesProps>( |
||||
({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => { |
||||
const cacheKey = useMemo(() => `${pubkey}-notes`, [pubkey]) |
||||
const [isRefreshing, setIsRefreshing] = useState(false) |
||||
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) |
||||
const bottomRef = useRef<HTMLDivElement>(null) |
||||
|
||||
const { events: timelineEvents, isLoading, refresh } = useProfileNotesTimeline({ |
||||
pubkey, |
||||
cacheKey, |
||||
kinds: NOTES_KIND_LIST, |
||||
limit: 200, |
||||
filterPredicate: undefined |
||||
}) |
||||
|
||||
useEffect(() => { |
||||
onEventsChange?.(timelineEvents) |
||||
}, [timelineEvents, onEventsChange]) |
||||
|
||||
useEffect(() => { |
||||
if (!isLoading) { |
||||
setIsRefreshing(false) |
||||
} |
||||
}, [isLoading]) |
||||
|
||||
useImperativeHandle( |
||||
ref, |
||||
() => ({ |
||||
refresh: () => { |
||||
setIsRefreshing(true) |
||||
refresh() |
||||
}, |
||||
getEvents: () => timelineEvents |
||||
}), |
||||
[refresh, timelineEvents] |
||||
) |
||||
|
||||
const getKindLabel = (kindValue: string) => { |
||||
if (!kindValue || kindValue === 'all') return 'notes' |
||||
const kindNum = parseInt(kindValue, 10) |
||||
if (kindNum === ExtendedKind.PUBLICATION_CONTENT) return 'notes' |
||||
if (kindNum === ExtendedKind.CITATION_INTERNAL) return 'internal citations' |
||||
if (kindNum === ExtendedKind.CITATION_EXTERNAL) return 'external citations' |
||||
if (kindNum === ExtendedKind.CITATION_HARDCOPY) return 'hardcopy citations' |
||||
if (kindNum === ExtendedKind.CITATION_PROMPT) return 'prompt citations' |
||||
return 'notes' |
||||
} |
||||
|
||||
const eventsFilteredByKind = useMemo(() => { |
||||
if (kindFilter === 'all') { |
||||
return timelineEvents |
||||
} |
||||
const kindNumber = parseInt(kindFilter, 10) |
||||
if (Number.isNaN(kindNumber)) { |
||||
return timelineEvents |
||||
} |
||||
return timelineEvents.filter((event) => event.kind === kindNumber) |
||||
}, [timelineEvents, kindFilter]) |
||||
|
||||
const filteredEvents = useMemo(() => { |
||||
if (!searchQuery.trim()) { |
||||
return eventsFilteredByKind |
||||
} |
||||
const query = searchQuery.toLowerCase().trim() |
||||
return eventsFilteredByKind.filter((event) => { |
||||
const contentLower = event.content.toLowerCase() |
||||
if (contentLower.includes(query)) return true |
||||
return event.tags.some((tag) => { |
||||
if (tag.length <= 1) return false |
||||
const tagValue = tag[1] |
||||
return tagValue && tagValue.toLowerCase().includes(query) |
||||
}) |
||||
}) |
||||
}, [eventsFilteredByKind, searchQuery]) |
||||
|
||||
// Reset showCount when filters change
|
||||
useEffect(() => { |
||||
setShowCount(INITIAL_SHOW_COUNT) |
||||
}, [searchQuery, kindFilter, pubkey]) |
||||
|
||||
// Pagination: slice to showCount for display
|
||||
const displayedEvents = useMemo(() => { |
||||
return filteredEvents.slice(0, showCount) |
||||
}, [filteredEvents, showCount]) |
||||
|
||||
// IntersectionObserver for infinite scroll
|
||||
useEffect(() => { |
||||
if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return |
||||
|
||||
const observer = new IntersectionObserver( |
||||
(entries) => { |
||||
if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) { |
||||
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length)) |
||||
} |
||||
}, |
||||
{ threshold: 0.1 } |
||||
) |
||||
|
||||
observer.observe(bottomRef.current) |
||||
|
||||
return () => { |
||||
observer.disconnect() |
||||
} |
||||
}, [displayedEvents.length, filteredEvents.length]) |
||||
|
||||
if (!pubkey) { |
||||
return ( |
||||
<div className="flex justify-center items-center py-8"> |
||||
<div className="text-sm text-muted-foreground">No profile selected</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (isLoading && timelineEvents.length === 0) { |
||||
return ( |
||||
<div className="space-y-2"> |
||||
{Array.from({ length: 3 }).map((_, i) => ( |
||||
<Skeleton key={i} className="h-32 w-full" /> |
||||
))} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (!filteredEvents.length && !isLoading) { |
||||
return ( |
||||
<div className="flex justify-center items-center py-8"> |
||||
<div className="text-sm text-muted-foreground"> |
||||
{searchQuery.trim() ? 'No notes match your search' : 'No notes found'} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div style={{ marginTop: topSpace || 0 }}> |
||||
{isRefreshing && ( |
||||
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing notes...</div> |
||||
)} |
||||
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && ( |
||||
<div className="px-4 py-2 text-sm text-muted-foreground"> |
||||
Showing {displayedEvents.length} of {filteredEvents.length} {getKindLabel(kindFilter)} |
||||
</div> |
||||
)} |
||||
<div className="space-y-2"> |
||||
{displayedEvents.map((event) => ( |
||||
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} /> |
||||
))} |
||||
</div> |
||||
{displayedEvents.length < filteredEvents.length && ( |
||||
<div ref={bottomRef} className="h-10 flex items-center justify-center"> |
||||
<div className="text-sm text-muted-foreground">Loading more...</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
) |
||||
|
||||
ProfileNotes.displayName = 'ProfileNotes' |
||||
|
||||
export default ProfileNotes |
||||
|
||||
@ -1,27 +0,0 @@
@@ -1,27 +0,0 @@
|
||||
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { UserRound } from 'lucide-react' |
||||
import SidebarItem from './SidebarItem' |
||||
|
||||
export default function ProfileButton() { |
||||
const { navigate, current, display } = usePrimaryPage() |
||||
const { primaryViewType } = usePrimaryNoteView() |
||||
const { checkLogin } = useNostr() |
||||
|
||||
// Profile button is active when:
|
||||
// 1. Profile is the current primary page AND there's no overlay (primaryViewType === null)
|
||||
// 2. OR primaryViewType is 'profile' (overlay profile)
|
||||
const isActive =
|
||||
(display && current === 'profile' && primaryViewType === null) || |
||||
primaryViewType === 'profile' |
||||
|
||||
return ( |
||||
<SidebarItem |
||||
title="Profile" |
||||
onClick={() => checkLogin(() => navigate('profile'))} |
||||
active={isActive} |
||||
> |
||||
<UserRound strokeWidth={3} /> |
||||
</SidebarItem> |
||||
) |
||||
} |
||||
@ -1,194 +0,0 @@
@@ -1,194 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react' |
||||
import { Event } from 'nostr-tools' |
||||
import client from '@/services/client.service' |
||||
import { FAST_READ_RELAY_URLS } from '@/constants' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import { getPrivateRelayUrls } from '@/lib/private-relays' |
||||
|
||||
type ProfileNotesTimelineCacheEntry = { |
||||
events: Event[] |
||||
lastUpdated: number |
||||
} |
||||
|
||||
const timelineCache = new Map<string, ProfileNotesTimelineCacheEntry>() |
||||
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes - cache is considered fresh for this long
|
||||
|
||||
type UseProfileNotesTimelineOptions = { |
||||
pubkey: string |
||||
cacheKey: string |
||||
kinds: number[] |
||||
limit?: number |
||||
filterPredicate?: (event: Event) => boolean |
||||
} |
||||
|
||||
type UseProfileNotesTimelineResult = { |
||||
events: Event[] |
||||
isLoading: boolean |
||||
refresh: () => void |
||||
} |
||||
|
||||
function postProcessEvents( |
||||
rawEvents: Event[], |
||||
filterPredicate: ((event: Event) => boolean) | undefined, |
||||
limit: number |
||||
) { |
||||
const dedupMap = new Map<string, Event>() |
||||
rawEvents.forEach((evt) => { |
||||
if (!dedupMap.has(evt.id)) { |
||||
dedupMap.set(evt.id, evt) |
||||
} |
||||
}) |
||||
|
||||
let events = Array.from(dedupMap.values()) |
||||
if (filterPredicate) { |
||||
events = events.filter(filterPredicate) |
||||
} |
||||
events.sort((a, b) => b.created_at - a.created_at) |
||||
return events.slice(0, limit) |
||||
} |
||||
|
||||
export function useProfileNotesTimeline({ |
||||
pubkey, |
||||
cacheKey, |
||||
kinds, |
||||
limit = 200, |
||||
filterPredicate |
||||
}: UseProfileNotesTimelineOptions): UseProfileNotesTimelineResult { |
||||
const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey]) |
||||
const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? []) |
||||
const [isLoading, setIsLoading] = useState(!cachedEntry) |
||||
const [refreshToken, setRefreshToken] = useState(0) |
||||
const subscriptionRef = useRef<() => void>(() => {}) |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
|
||||
const subscribe = async () => { |
||||
// Check if we have fresh cached data
|
||||
const cachedEntry = timelineCache.get(cacheKey) |
||||
const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity |
||||
const isCacheFresh = cacheAge < CACHE_DURATION |
||||
|
||||
// If cache is fresh, show it immediately and skip subscribing
|
||||
if (isCacheFresh && cachedEntry) { |
||||
setEvents(cachedEntry.events) |
||||
setIsLoading(false) |
||||
// Still subscribe in background to get updates, but don't show loading
|
||||
} else { |
||||
// Cache is stale or missing - show loading and fetch
|
||||
setIsLoading(!cachedEntry) |
||||
} |
||||
|
||||
try { |
||||
// Get private relays (outbox + cache relays) for private notes
|
||||
const privateRelayUrls = await getPrivateRelayUrls(pubkey) |
||||
const normalizedPrivateRelays = Array.from( |
||||
new Set( |
||||
privateRelayUrls |
||||
.map((url) => normalizeUrl(url)) |
||||
.filter((value): value is string => !!value) |
||||
) |
||||
) |
||||
|
||||
// Also include fast read relays as fallback
|
||||
const fastReadRelays = Array.from( |
||||
new Set( |
||||
FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url) |
||||
) |
||||
) |
||||
|
||||
// Build relay groups: private relays first, then fast read relays
|
||||
const relayGroups: string[][] = [] |
||||
if (normalizedPrivateRelays.length > 0) { |
||||
relayGroups.push(normalizedPrivateRelays) |
||||
} |
||||
if (fastReadRelays.length > 0) { |
||||
relayGroups.push(fastReadRelays) |
||||
} |
||||
|
||||
if (cancelled) { |
||||
return |
||||
} |
||||
|
||||
const subRequests = relayGroups |
||||
.map((urls) => ({ |
||||
urls, |
||||
filter: { |
||||
authors: [pubkey], |
||||
kinds, |
||||
limit |
||||
} as any |
||||
})) |
||||
.filter((request) => request.urls.length) |
||||
|
||||
if (!subRequests.length) { |
||||
timelineCache.set(cacheKey, { |
||||
events: [], |
||||
lastUpdated: Date.now() |
||||
}) |
||||
setEvents([]) |
||||
setIsLoading(false) |
||||
return |
||||
} |
||||
|
||||
const { closer } = await client.subscribeTimeline( |
||||
subRequests, |
||||
{ |
||||
onEvents: (fetchedEvents) => { |
||||
if (cancelled) return |
||||
const processed = postProcessEvents(fetchedEvents as Event[], filterPredicate, limit) |
||||
timelineCache.set(cacheKey, { |
||||
events: processed, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
setEvents(processed) |
||||
setIsLoading(false) |
||||
}, |
||||
onNew: (evt) => { |
||||
if (cancelled) return |
||||
setEvents((prevEvents) => { |
||||
const combined = [evt as Event, ...prevEvents] |
||||
const processed = postProcessEvents(combined, filterPredicate, limit) |
||||
timelineCache.set(cacheKey, { |
||||
events: processed, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
return processed |
||||
}) |
||||
} |
||||
}, |
||||
{ needSort: true, useCache: false } // NO CACHING - stream raw from relays
|
||||
) |
||||
|
||||
subscriptionRef.current = () => closer() |
||||
} catch (error) { |
||||
if (!cancelled) { |
||||
setIsLoading(false) |
||||
} |
||||
} |
||||
} |
||||
|
||||
subscribe() |
||||
|
||||
return () => { |
||||
cancelled = true |
||||
subscriptionRef.current() |
||||
subscriptionRef.current = () => {} |
||||
} |
||||
}, [pubkey, cacheKey, JSON.stringify(kinds), limit, filterPredicate, refreshToken]) |
||||
|
||||
const refresh = useCallback(() => { |
||||
subscriptionRef.current() |
||||
subscriptionRef.current = () => {} |
||||
timelineCache.delete(cacheKey) |
||||
setIsLoading(true) |
||||
setRefreshToken((token) => token + 1) |
||||
}, [cacheKey]) |
||||
|
||||
return { |
||||
events, |
||||
isLoading, |
||||
refresh |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
|
||||
import { useCallback, useEffect, useState } from 'react' |
||||
import { Event } from 'nostr-tools' |
||||
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' |
||||
import logger from '@/lib/logger' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import client from '@/services/client.service' |
||||
import { queryService, replaceableEventService } from '@/services/client.service' |
||||
|
||||
const CACHE_DURATION = 5 * 60 * 1000 |
||||
|
||||
type PinsCacheEntry = { |
||||
events: Event[] |
||||
lastUpdated: number |
||||
} |
||||
|
||||
const pinsCache = new Map<string, PinsCacheEntry>() |
||||
|
||||
function orderPinEvents(pinList: Event, eventsById: Map<string, Event>): Event[] { |
||||
const ordered: Event[] = [] |
||||
const seen = new Set<string>() |
||||
|
||||
const eIds = pinList.tags |
||||
.filter((tag) => tag[0] === 'e' && tag[1]) |
||||
.map((tag) => tag[1]) |
||||
.reverse() |
||||
|
||||
for (const id of eIds) { |
||||
const ev = eventsById.get(id) |
||||
if (ev && !seen.has(ev.id)) { |
||||
ordered.push(ev) |
||||
seen.add(ev.id) |
||||
} |
||||
} |
||||
|
||||
const aTags = pinList.tags.filter((tag) => tag[0] === 'a' && tag[1]).map((tag) => tag[1]) |
||||
for (const coord of aTags) { |
||||
const ev = [...eventsById.values()].find((e) => { |
||||
const d = e.tags.find((t) => t[0] === 'd')?.[1] ?? '' |
||||
return `${e.kind}:${e.pubkey}:${d}` === coord |
||||
}) |
||||
if (ev && !seen.has(ev.id)) { |
||||
ordered.push(ev) |
||||
seen.add(ev.id) |
||||
} |
||||
} |
||||
|
||||
for (const ev of eventsById.values()) { |
||||
if (!seen.has(ev.id)) { |
||||
ordered.push(ev) |
||||
seen.add(ev.id) |
||||
} |
||||
} |
||||
|
||||
return ordered |
||||
} |
||||
|
||||
export function useProfilePins(pubkey: string | undefined) { |
||||
const { pubkey: myPubkey } = useNostr() |
||||
const { favoriteRelays } = useFavoriteRelays() |
||||
const [pinEvents, setPinEvents] = useState<Event[]>([]) |
||||
const [loadingPins, setLoadingPins] = useState(false) |
||||
|
||||
const buildComprehensiveRelayList = useCallback(async () => { |
||||
const myRelayList = myPubkey ? await client.fetchRelayList(myPubkey) : { write: [], read: [] } |
||||
const allRelays = [ |
||||
...(myRelayList.read || []), |
||||
...(myRelayList.write || []), |
||||
...(favoriteRelays || []), |
||||
...FAST_READ_RELAY_URLS, |
||||
...FAST_WRITE_RELAY_URLS |
||||
] |
||||
const normalized = allRelays.map((url) => normalizeUrl(url)).filter((url): url is string => !!url) |
||||
return Array.from(new Set(normalized)) |
||||
}, [myPubkey, favoriteRelays]) |
||||
|
||||
const loadPins = useCallback( |
||||
async (forceRefresh = false) => { |
||||
if (!pubkey) { |
||||
setPinEvents([]) |
||||
return |
||||
} |
||||
const cacheKey = `${pubkey}-pins-profile` |
||||
if (!forceRefresh) { |
||||
const cached = pinsCache.get(cacheKey) |
||||
if (cached && Date.now() - cached.lastUpdated < CACHE_DURATION) { |
||||
setPinEvents(cached.events) |
||||
cached.events.forEach((e) => client.addEventToCache(e)) |
||||
return |
||||
} |
||||
} |
||||
|
||||
setLoadingPins(true) |
||||
try { |
||||
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||
let pinList: Event | null = null |
||||
try { |
||||
const pinListEvents = await queryService.fetchEvents(comprehensiveRelays, { |
||||
authors: [pubkey], |
||||
kinds: [10001], |
||||
limit: 1 |
||||
}) |
||||
pinList = pinListEvents[0] || null |
||||
} catch { |
||||
pinList = (await replaceableEventService.fetchReplaceableEvent(pubkey, 10001)) ?? null |
||||
} |
||||
|
||||
if (!pinList?.tags?.length) { |
||||
setPinEvents([]) |
||||
pinsCache.set(cacheKey, { events: [], lastUpdated: Date.now() }) |
||||
return |
||||
} |
||||
|
||||
const eventIds = pinList.tags.filter((tag) => tag[0] === 'e' && tag[1]).map((tag) => tag[1]) |
||||
const aTags = pinList.tags.filter((tag) => tag[0] === 'a' && tag[1]).map((tag) => tag[1]) |
||||
|
||||
const eventPromises: Promise<Event[]>[] = [] |
||||
if (eventIds.length > 0) { |
||||
eventPromises.push( |
||||
queryService.fetchEvents(comprehensiveRelays, { ids: eventIds, limit: 100 }) |
||||
) |
||||
} |
||||
if (aTags.length > 0) { |
||||
const aTagFetches = aTags.map(async (aTag) => { |
||||
const parts = aTag.split(':') |
||||
if (parts.length < 2) return null |
||||
const kind = parseInt(parts[0], 10) |
||||
const author = parts[1] |
||||
const d = parts[2] || '' |
||||
const filter = d |
||||
? { authors: [author], kinds: [kind], limit: 1, '#d': [d] as [string] } |
||||
: { authors: [author], kinds: [kind], limit: 1 } |
||||
const events = await queryService.fetchEvents(comprehensiveRelays, [filter]) |
||||
return events[0] || null |
||||
}) |
||||
eventPromises.push( |
||||
Promise.all(aTagFetches).then((events) => events.filter((e): e is Event => e !== null)) |
||||
) |
||||
} |
||||
|
||||
const eventArrays = await Promise.all(eventPromises) |
||||
const flat = eventArrays.flat() |
||||
flat.forEach((e) => client.addEventToCache(e)) |
||||
|
||||
const byId = new Map<string, Event>() |
||||
for (const e of flat) { |
||||
byId.set(e.id, e) |
||||
} |
||||
|
||||
const ordered = orderPinEvents(pinList, byId) |
||||
setPinEvents(ordered) |
||||
pinsCache.set(cacheKey, { events: ordered, lastUpdated: Date.now() }) |
||||
} catch (e) { |
||||
logger.warn('[useProfilePins] Failed to load pins', e) |
||||
setPinEvents([]) |
||||
} finally { |
||||
setLoadingPins(false) |
||||
} |
||||
}, |
||||
[pubkey, buildComprehensiveRelayList] |
||||
) |
||||
|
||||
useEffect(() => { |
||||
if (!pubkey) { |
||||
setPinEvents([]) |
||||
return |
||||
} |
||||
const t = setTimeout(() => void loadPins(false), 200) |
||||
return () => clearTimeout(t) |
||||
}, [pubkey, loadPins]) |
||||
|
||||
const refreshPins = useCallback(() => { |
||||
if (pubkey) { |
||||
pinsCache.delete(`${pubkey}-pins-profile`) |
||||
} |
||||
void loadPins(true) |
||||
}, [pubkey, loadPins]) |
||||
|
||||
return { pinEvents, loadingPins, refreshPins } |
||||
} |
||||
@ -1,107 +0,0 @@
@@ -1,107 +0,0 @@
|
||||
import FeedSwitcher from '@/components/FeedSwitcher' |
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' |
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
||||
import { simplifyUrl } from '@/lib/url' |
||||
import { cn } from '@/lib/utils' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useFeed } from '@/providers/FeedProvider' |
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||
import { BookmarkIcon, ChevronDown, Server, UsersRound } from 'lucide-react' |
||||
import { forwardRef, ButtonHTMLAttributes, useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function FeedButton({ className }: { className?: string }) { |
||||
const { t } = useTranslation() |
||||
const { isSmallScreen } = useScreenSize() |
||||
const [open, setOpen] = useState(false) |
||||
|
||||
if (isSmallScreen) { |
||||
return ( |
||||
<> |
||||
<FeedSwitcherTrigger className={className} onClick={() => setOpen(true)} /> |
||||
<Drawer open={open} onOpenChange={setOpen}> |
||||
<DrawerContent className="max-h-[80vh]"> |
||||
<DrawerHeader className="sr-only"> |
||||
<DrawerTitle>{t('Choose feed')}</DrawerTitle> |
||||
</DrawerHeader> |
||||
<div |
||||
className="overflow-y-auto overscroll-contain py-2 px-4" |
||||
style={{ touchAction: 'pan-y' }} |
||||
> |
||||
<FeedSwitcher close={() => setOpen(false)} /> |
||||
</div> |
||||
</DrawerContent> |
||||
</Drawer> |
||||
</> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<Popover open={open} onOpenChange={setOpen}> |
||||
<PopoverTrigger asChild> |
||||
<FeedSwitcherTrigger className={className} /> |
||||
</PopoverTrigger> |
||||
<PopoverContent |
||||
sideOffset={0} |
||||
side="bottom" |
||||
className="w-96 p-4 max-h-[80vh] overflow-auto scrollbar-hide" |
||||
> |
||||
<FeedSwitcher close={() => setOpen(false)} /> |
||||
</PopoverContent> |
||||
</Popover> |
||||
) |
||||
} |
||||
|
||||
const FeedSwitcherTrigger = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>( |
||||
({ className, ...props }, ref) => { |
||||
const { t } = useTranslation() |
||||
const { feedInfo, relayUrls } = useFeed() |
||||
const { relaySets } = useFavoriteRelays() |
||||
const activeRelaySet = useMemo(() => { |
||||
return feedInfo.feedType === 'relays' && feedInfo.id |
||||
? relaySets.find((set) => set.id === feedInfo.id) |
||||
: undefined |
||||
}, [feedInfo, relaySets]) |
||||
const title = useMemo(() => { |
||||
if (feedInfo.feedType === 'following') { |
||||
return t('Following') |
||||
} |
||||
if (feedInfo.feedType === 'bookmarks') { |
||||
return t('Bookmarks') |
||||
} |
||||
if (feedInfo.feedType === 'all-favorites') { |
||||
return t('All favorite relays') |
||||
} |
||||
if (relayUrls.length === 0) { |
||||
return t('Choose a relay') |
||||
} |
||||
if (feedInfo.feedType === 'relay') { |
||||
return simplifyUrl(feedInfo.id ?? '') |
||||
} |
||||
if (feedInfo.feedType === 'relays') { |
||||
return activeRelaySet?.name ?? activeRelaySet?.id |
||||
} |
||||
}, [feedInfo, activeRelaySet]) |
||||
|
||||
return ( |
||||
<button |
||||
type="button" |
||||
className={cn('flex items-center gap-2 clickable px-3 h-full rounded-lg bg-transparent border-0 text-left', className)} |
||||
ref={ref} |
||||
{...props} |
||||
> |
||||
{feedInfo.feedType === 'following' ? ( |
||||
<UsersRound /> |
||||
) : feedInfo.feedType === 'bookmarks' ? ( |
||||
<BookmarkIcon /> |
||||
) : feedInfo.feedType === 'all-favorites' ? ( |
||||
<Server /> |
||||
) : ( |
||||
<Server /> |
||||
)} |
||||
<span className="text-lg font-semibold truncate">{title}</span> |
||||
<ChevronDown /> |
||||
</button> |
||||
) |
||||
} |
||||
) |
||||
@ -1,87 +0,0 @@
@@ -1,87 +0,0 @@
|
||||
import HideUntrustedContentButton from '@/components/HideUntrustedContentButton' |
||||
import NotificationList from '@/components/NotificationList' |
||||
import { RefreshButton } from '@/components/RefreshButton' |
||||
import Tabs from '@/components/Tabs' |
||||
import { usePrimaryPage } from '@/PageManager' |
||||
import { TNotificationType } from '@/types' |
||||
import { isTouchDevice } from '@/lib/utils' |
||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' |
||||
import { Bell } from 'lucide-react' |
||||
import { forwardRef, useEffect, useRef, useState, useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
const NotificationListPage = forwardRef((_, ref) => { |
||||
const { t } = useTranslation() |
||||
const { current } = usePrimaryPage() |
||||
const firstRenderRef = useRef(true) |
||||
const notificationListRef = useRef<{ refresh: () => void }>(null) |
||||
const [notificationType, setNotificationType] = useState<TNotificationType>('all') |
||||
const supportTouch = useMemo(() => isTouchDevice(), []) |
||||
|
||||
useEffect(() => { |
||||
if (current === 'notifications' && !firstRenderRef.current) { |
||||
notificationListRef.current?.refresh() |
||||
} |
||||
firstRenderRef.current = false |
||||
}, [current]) |
||||
|
||||
useEffect(() => { |
||||
const handleRestore = (e: CustomEvent<{ page: string; tab: string }>) => { |
||||
if (e.detail.page === 'notifications' && e.detail.tab) { |
||||
setNotificationType(e.detail.tab as TNotificationType) |
||||
} |
||||
} |
||||
window.addEventListener('restorePageTab', handleRestore as EventListener) |
||||
return () => window.removeEventListener('restorePageTab', handleRestore as EventListener) |
||||
}, []) |
||||
|
||||
return ( |
||||
<PrimaryPageLayout |
||||
ref={ref} |
||||
pageName="notifications" |
||||
titlebar={<NotificationListPageTitlebar />} |
||||
subHeader={ |
||||
<Tabs |
||||
value={notificationType} |
||||
tabs={[ |
||||
{ value: 'all', label: t('All') }, |
||||
{ value: 'mentions', label: t('Mentions') }, |
||||
{ value: 'reactions', label: t('Reactions') }, |
||||
{ value: 'zaps', label: t('Zaps') } |
||||
]} |
||||
onTabChange={(tab) => { |
||||
setNotificationType(tab as TNotificationType) |
||||
window.dispatchEvent(new CustomEvent('pageTabChanged', { |
||||
detail: { page: 'notifications', tab } |
||||
})) |
||||
}} |
||||
options={!supportTouch ? <RefreshButton onClick={() => notificationListRef.current?.refresh()} /> : null} |
||||
/> |
||||
} |
||||
displayScrollToTopButton |
||||
> |
||||
<div className="min-w-0 pt-2"> |
||||
<NotificationList |
||||
ref={notificationListRef} |
||||
notificationType={notificationType} |
||||
/> |
||||
</div> |
||||
</PrimaryPageLayout> |
||||
) |
||||
}) |
||||
NotificationListPage.displayName = 'NotificationListPage' |
||||
export default NotificationListPage |
||||
|
||||
function NotificationListPageTitlebar() { |
||||
const { t } = useTranslation() |
||||
|
||||
return ( |
||||
<div className="flex gap-2 items-center justify-between h-full pl-3"> |
||||
<div className="flex items-center gap-2"> |
||||
<Bell /> |
||||
<div className="text-lg font-semibold">{t('Notifications')}</div> |
||||
</div> |
||||
<HideUntrustedContentButton type="notifications" size="titlebar-icon" /> |
||||
</div> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue