179 lines
6.0 KiB
TypeScript
179 lines
6.0 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { nostrService } from '@/lib/nostr'
|
|
import type { Article } from '@/types/nostr'
|
|
import { applyFiltersAndSort } from '@/lib/articleFiltering'
|
|
import type { ArticleFilters } from '@/components/ArticleFilters'
|
|
import { t } from '@/lib/i18n'
|
|
import { objectCache } from '@/lib/objectCache'
|
|
import { getAuthorSponsoring } from '@/lib/sponsoring'
|
|
|
|
export function useArticles(searchQuery: string = '', filters: ArticleFilters | null = null): {
|
|
articles: Article[]
|
|
allArticles: Article[]
|
|
loading: boolean
|
|
error: string | null
|
|
loadArticleContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
|
|
} {
|
|
const [articles, setArticles] = useState<Article[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const hasArticlesRef = useRef(false)
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
// Load authors from cache first
|
|
const loadAuthorsFromCache = async (): Promise<boolean> => {
|
|
try {
|
|
const cachedAuthors = await objectCache.getAll('author')
|
|
const authors = cachedAuthors as Article[]
|
|
|
|
// Display authors immediately (with existing totalSponsoring if available)
|
|
if (authors.length > 0) {
|
|
setArticles((prev) => {
|
|
// Merge with existing articles, avoiding duplicates
|
|
const existingIds = new Set(prev.map((a) => a.id))
|
|
const newAuthors = authors.filter((a) => !existingIds.has(a.id))
|
|
const merged = [...prev, ...newAuthors].sort((a, b) => b.createdAt - a.createdAt)
|
|
hasArticlesRef.current = merged.length > 0
|
|
return merged
|
|
})
|
|
setLoading(false)
|
|
|
|
// Calculate totalSponsoring asynchronously from cache (non-blocking)
|
|
// Only update authors that don't have totalSponsoring yet
|
|
const authorsNeedingSponsoring = authors.filter(
|
|
(author) => author.isPresentation && author.pubkey && author.totalSponsoring === undefined
|
|
)
|
|
|
|
if (authorsNeedingSponsoring.length > 0) {
|
|
// Load sponsoring from cache in parallel (fast, no network)
|
|
const sponsoringPromises = authorsNeedingSponsoring.map(async (author) => {
|
|
if (author.pubkey) {
|
|
const totalSponsoring = await getAuthorSponsoring(author.pubkey, true)
|
|
return { authorId: author.id, totalSponsoring }
|
|
}
|
|
return null
|
|
})
|
|
|
|
const sponsoringResults = await Promise.all(sponsoringPromises)
|
|
|
|
// Update articles with sponsoring amounts
|
|
setArticles((prev) =>
|
|
prev.map((article) => {
|
|
const sponsoringResult = sponsoringResults.find((r) => r?.authorId === article.id)
|
|
if (sponsoringResult && article.isPresentation) {
|
|
return { ...article, totalSponsoring: sponsoringResult.totalSponsoring }
|
|
}
|
|
return article
|
|
})
|
|
)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Cache is empty - stop loading immediately, no network requests needed
|
|
setLoading(false)
|
|
hasArticlesRef.current = false
|
|
return false
|
|
} catch (error) {
|
|
console.error('Error loading authors from cache:', error)
|
|
setLoading(false)
|
|
return false
|
|
}
|
|
}
|
|
|
|
let unsubscribe: (() => void) | null = null
|
|
let timeout: NodeJS.Timeout | null = null
|
|
|
|
void loadAuthorsFromCache().then((hasCachedAuthors) => {
|
|
// Only subscribe to network if cache is empty (to fetch new content)
|
|
// If cache has authors, we can skip network subscription for faster load
|
|
if (!hasCachedAuthors) {
|
|
unsubscribe = nostrService.subscribeToArticles(
|
|
(article) => {
|
|
setArticles((prev) => {
|
|
if (prev.some((a) => a.id === article.id)) {
|
|
return prev
|
|
}
|
|
const next = [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
|
|
hasArticlesRef.current = next.length > 0
|
|
return next
|
|
})
|
|
setLoading(false)
|
|
},
|
|
50
|
|
)
|
|
|
|
// Shorter timeout if cache is empty (5 seconds instead of 10)
|
|
timeout = setTimeout(() => {
|
|
setLoading(false)
|
|
if (!hasArticlesRef.current) {
|
|
setError(t('common.error.noContent'))
|
|
}
|
|
}, 5000)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
if (unsubscribe) {
|
|
unsubscribe()
|
|
}
|
|
if (timeout) {
|
|
clearTimeout(timeout)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const loadArticleContent = async (articleId: string, authorPubkey: string): Promise<Article | null> => {
|
|
try {
|
|
const article = await nostrService.getArticleById(articleId)
|
|
if (article) {
|
|
// Try to decrypt article content using decryption key from private messages
|
|
const decryptedContent = await nostrService.getDecryptedArticleContent(articleId, authorPubkey)
|
|
if (decryptedContent) {
|
|
setArticles((prev) =>
|
|
prev.map((a) =>
|
|
(a.id === articleId
|
|
? { ...a, content: decryptedContent, paid: true }
|
|
: a)
|
|
)
|
|
)
|
|
}
|
|
return article
|
|
}
|
|
} catch (e) {
|
|
console.error('Error loading article content:', e)
|
|
setError(e instanceof Error ? e.message : 'Failed to load article')
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Apply filters and sorting
|
|
const filteredArticles = useMemo(() => {
|
|
const effectiveFilters =
|
|
filters ??
|
|
({
|
|
authorPubkey: null,
|
|
sortBy: 'newest',
|
|
category: 'all',
|
|
} as const)
|
|
|
|
if (!filters && !searchQuery.trim()) {
|
|
return articles
|
|
}
|
|
|
|
return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
|
|
}, [articles, searchQuery, filters])
|
|
|
|
return {
|
|
articles: filteredArticles,
|
|
allArticles: articles, // Return all articles for filters component
|
|
loading,
|
|
error,
|
|
loadArticleContent,
|
|
}
|
|
}
|