33 changed files with 950 additions and 2536 deletions
@ -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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
|
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
|
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 @@ |
|||||||
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 @@ |
|||||||
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