|
|
|
|
@ -27,29 +27,30 @@ let cachedCustomEvents: {
@@ -27,29 +27,30 @@ let cachedCustomEvents: {
|
|
|
|
|
// Flag to prevent concurrent initialization
|
|
|
|
|
let isInitializing = false |
|
|
|
|
|
|
|
|
|
type TrendingTab = 'band' | 'relays' | 'bookmarks' | 'hashtags' |
|
|
|
|
type SortOrder = 'newest' | 'oldest' | 'most-popular' | 'least-popular' |
|
|
|
|
type BookmarkFilter = 'yours' | 'follows' |
|
|
|
|
type HashtagFilter = 'popular' |
|
|
|
|
|
|
|
|
|
export default function TrendingNotes() { |
|
|
|
|
const { t } = useTranslation() |
|
|
|
|
const { isEventDeleted } = useDeletedEvent() |
|
|
|
|
const { hideUntrustedNotes, isUserTrusted } = useUserTrust() |
|
|
|
|
const { pubkey, relayList, bookmarkListEvent, interestListEvent } = useNostr() |
|
|
|
|
const { pubkey, relayList, bookmarkListEvent } = useNostr() |
|
|
|
|
const { favoriteRelays } = useFavoriteRelays() |
|
|
|
|
const { zapReplyThreshold } = useZap() |
|
|
|
|
const [trendingNotes, setTrendingNotes] = useState<NostrEvent[]>([]) |
|
|
|
|
const [showCount, setShowCount] = useState(10) |
|
|
|
|
const [loading, setLoading] = useState(true) |
|
|
|
|
const [activeTab, setActiveTab] = useState<TrendingTab>('band') |
|
|
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>('most-popular') |
|
|
|
|
const [bookmarkFilter] = useState<BookmarkFilter>('yours') |
|
|
|
|
const [hashtagFilter] = useState<HashtagFilter>('popular') |
|
|
|
|
const [selectedHashtag, setSelectedHashtag] = useState<string | null>(null) |
|
|
|
|
const [popularHashtags, setPopularHashtags] = useState<string[]>([]) |
|
|
|
|
const [cacheEvents, setCacheEvents] = useState<NostrEvent[]>([]) |
|
|
|
|
const bottomRef = useRef<HTMLDivElement>(null) |
|
|
|
|
|
|
|
|
|
// Extract hashtags from interest list (kind 10015)
|
|
|
|
|
const hashtags = useMemo(() => { |
|
|
|
|
if (!interestListEvent) return [] |
|
|
|
|
const tags: string[] = [] |
|
|
|
|
interestListEvent.tags.forEach((tag) => { |
|
|
|
|
if (tag[0] === 't' && tag[1]) { |
|
|
|
|
tags.push(tag[1]) |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
return tags |
|
|
|
|
}, [interestListEvent]) |
|
|
|
|
|
|
|
|
|
// Extract event IDs from bookmark and pin lists (kinds 10003 and 10001)
|
|
|
|
|
const listEventIds = useMemo(() => { |
|
|
|
|
@ -70,6 +71,108 @@ export default function TrendingNotes() {
@@ -70,6 +71,108 @@ export default function TrendingNotes() {
|
|
|
|
|
return eventIds |
|
|
|
|
}, [bookmarkListEvent]) |
|
|
|
|
|
|
|
|
|
// Fetch bookmark/pin lists from follows
|
|
|
|
|
const [followsBookmarkEventIds, setFollowsBookmarkEventIds] = useState<string[]>([]) |
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
const fetchFollowsBookmarks = async () => { |
|
|
|
|
if (!pubkey) return |
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
// Get follows list
|
|
|
|
|
const followPubkeys = await client.fetchFollowings(pubkey) |
|
|
|
|
if (!followPubkeys || followPubkeys.length === 0) return |
|
|
|
|
|
|
|
|
|
// Fetch bookmark and pin lists from follows
|
|
|
|
|
const bookmarkPromises = followPubkeys.map(async (followPubkey: string) => { |
|
|
|
|
try { |
|
|
|
|
const [bookmarkList, pinList] = await Promise.all([ |
|
|
|
|
client.fetchBookmarkListEvent(followPubkey), |
|
|
|
|
client.fetchPinListEvent(followPubkey) |
|
|
|
|
]) |
|
|
|
|
|
|
|
|
|
const eventIds: string[] = [] |
|
|
|
|
if (bookmarkList) { |
|
|
|
|
bookmarkList.tags.forEach(tag => { |
|
|
|
|
if (tag[0] === 'e' && tag[1]) { |
|
|
|
|
eventIds.push(tag[1]) |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
} |
|
|
|
|
if (pinList) { |
|
|
|
|
pinList.tags.forEach(tag => { |
|
|
|
|
if (tag[0] === 'e' && tag[1]) { |
|
|
|
|
eventIds.push(tag[1]) |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
} |
|
|
|
|
return eventIds |
|
|
|
|
} catch (error) { |
|
|
|
|
console.error(`Error fetching bookmarks for ${followPubkey}:`, error) |
|
|
|
|
return [] |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
const allEventIds = await Promise.all(bookmarkPromises) |
|
|
|
|
const flattenedIds = allEventIds.flat() |
|
|
|
|
setFollowsBookmarkEventIds(flattenedIds) |
|
|
|
|
} catch (error) { |
|
|
|
|
console.error('Error fetching follows bookmarks:', error) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fetchFollowsBookmarks() |
|
|
|
|
}, [pubkey]) |
|
|
|
|
|
|
|
|
|
// Calculate popular hashtags from cache events (all events from relays)
|
|
|
|
|
const calculatePopularHashtags = useMemo(() => { |
|
|
|
|
console.log('[TrendingNotes] calculatePopularHashtags - cacheEvents.length:', cacheEvents.length) |
|
|
|
|
if (cacheEvents.length === 0) { |
|
|
|
|
return [] |
|
|
|
|
} |
|
|
|
|
const hashtagCounts = new Map<string, number>() |
|
|
|
|
let eventsWithHashtags = 0 |
|
|
|
|
|
|
|
|
|
// For hashtag analysis, use all cache events
|
|
|
|
|
let eventsToAnalyze = cacheEvents |
|
|
|
|
|
|
|
|
|
eventsToAnalyze.forEach((event) => { |
|
|
|
|
let hasAnyHashtag = false |
|
|
|
|
|
|
|
|
|
// Count hashtags from 't' tags
|
|
|
|
|
event.tags.forEach(tag => { |
|
|
|
|
if (tag[0] === 't' && tag[1]) { |
|
|
|
|
const hashtag = tag[1].toLowerCase() |
|
|
|
|
hashtagCounts.set(hashtag, (hashtagCounts.get(hashtag) || 0) + 1) |
|
|
|
|
hasAnyHashtag = true |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
// Count hashtags from content (simple regex for #hashtag)
|
|
|
|
|
const contentHashtags = event.content.match(/#[a-zA-Z0-9_]+/g) |
|
|
|
|
if (contentHashtags) { |
|
|
|
|
contentHashtags.forEach(hashtag => { |
|
|
|
|
const cleanHashtag = hashtag.slice(1).toLowerCase() // Remove #
|
|
|
|
|
hashtagCounts.set(cleanHashtag, (hashtagCounts.get(cleanHashtag) || 0) + 1) |
|
|
|
|
hasAnyHashtag = true |
|
|
|
|
}) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (hasAnyHashtag) eventsWithHashtags++ |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
// Sort by count and return top 10
|
|
|
|
|
const result = Array.from(hashtagCounts.entries()) |
|
|
|
|
.sort(([, a], [, b]) => b - a) |
|
|
|
|
.slice(0, 10) |
|
|
|
|
.map(([hashtag]) => hashtag) |
|
|
|
|
|
|
|
|
|
console.log('[TrendingNotes] calculatePopularHashtags - found hashtags:', result) |
|
|
|
|
console.log('[TrendingNotes] calculatePopularHashtags - eventsWithHashtags:', eventsWithHashtags) |
|
|
|
|
|
|
|
|
|
return result |
|
|
|
|
}, [cacheEvents, activeTab, hashtagFilter, pubkey]) // Use cacheEvents as dependency
|
|
|
|
|
|
|
|
|
|
// Get relays based on user login status
|
|
|
|
|
const getRelays = useMemo(() => { |
|
|
|
|
const relays: string[] = [] |
|
|
|
|
@ -94,12 +197,18 @@ export default function TrendingNotes() {
@@ -94,12 +197,18 @@ export default function TrendingNotes() {
|
|
|
|
|
return Array.from(new Set(normalized)) |
|
|
|
|
}, [pubkey, favoriteRelays, relayList]) |
|
|
|
|
|
|
|
|
|
// Update popular hashtags when trending notes change
|
|
|
|
|
useEffect(() => { |
|
|
|
|
console.log('[TrendingNotes] calculatePopularHashtags result:', calculatePopularHashtags) |
|
|
|
|
setPopularHashtags(calculatePopularHashtags) |
|
|
|
|
}, [calculatePopularHashtags]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize cache only once on mount
|
|
|
|
|
useEffect(() => { |
|
|
|
|
const initializeCache = async () => { |
|
|
|
|
// Prevent concurrent initialization
|
|
|
|
|
if (isInitializing) { |
|
|
|
|
console.log('[TrendingNotes] Already initializing, skipping') |
|
|
|
|
return |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -107,18 +216,14 @@ export default function TrendingNotes() {
@@ -107,18 +216,14 @@ export default function TrendingNotes() {
|
|
|
|
|
|
|
|
|
|
// Check if cache is still valid
|
|
|
|
|
if (cachedCustomEvents && (now - cachedCustomEvents.timestamp) < CACHE_DURATION) { |
|
|
|
|
console.log('[TrendingNotes] Using existing cache') |
|
|
|
|
return |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
isInitializing = true |
|
|
|
|
console.log('[TrendingNotes] Initializing cache from relays') |
|
|
|
|
const relays = getRelays |
|
|
|
|
console.log('[TrendingNotes] Using', relays.length, 'relays:', relays) |
|
|
|
|
|
|
|
|
|
// Prevent running if we have no relays
|
|
|
|
|
if (relays.length === 0) { |
|
|
|
|
console.log('[TrendingNotes] No relays available, skipping cache initialization') |
|
|
|
|
return |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -126,28 +231,48 @@ export default function TrendingNotes() {
@@ -126,28 +231,48 @@ export default function TrendingNotes() {
|
|
|
|
|
const allEvents: NostrEvent[] = [] |
|
|
|
|
const twentyFourHoursAgo = Math.floor(Date.now() / 1000) - 24 * 60 * 60 |
|
|
|
|
|
|
|
|
|
// 1. Fetch top-level posts from last 24 hours - query each relay individually
|
|
|
|
|
const recentEventsPromises = relays.map(async (relay) => { |
|
|
|
|
const events = await client.fetchEvents([relay], { |
|
|
|
|
kinds: [1, 11, 30023, 9802, 20, 21, 22], |
|
|
|
|
since: twentyFourHoursAgo, |
|
|
|
|
limit: 500 |
|
|
|
|
// 1. Fetch top-level posts from last 24 hours - batch requests to avoid overwhelming relays
|
|
|
|
|
const batchSize = 3 // Process 3 relays at a time
|
|
|
|
|
const recentEvents: NostrEvent[] = [] |
|
|
|
|
|
|
|
|
|
for (let i = 0; i < relays.length; i += batchSize) { |
|
|
|
|
const batch = relays.slice(i, i + batchSize) |
|
|
|
|
const batchPromises = batch.map(async (relay) => { |
|
|
|
|
try { |
|
|
|
|
const events = await client.fetchEvents([relay], { |
|
|
|
|
kinds: [1, 11, 30023, 9802, 20, 21, 22], |
|
|
|
|
since: twentyFourHoursAgo, |
|
|
|
|
limit: 500 |
|
|
|
|
}) |
|
|
|
|
return events |
|
|
|
|
} catch (error) { |
|
|
|
|
console.warn(`[TrendingNotes] Error fetching from relay ${relay}:`, error) |
|
|
|
|
return [] |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
return events |
|
|
|
|
}) |
|
|
|
|
const recentEventsArrays = await Promise.all(recentEventsPromises) |
|
|
|
|
const recentEvents = recentEventsArrays.flat() |
|
|
|
|
console.log('[TrendingNotes] Fetched', recentEvents.length, 'recent events from', relays.length, 'relays') |
|
|
|
|
|
|
|
|
|
const batchResults = await Promise.all(batchPromises) |
|
|
|
|
recentEvents.push(...batchResults.flat()) |
|
|
|
|
|
|
|
|
|
// Add a small delay between batches to be respectful to relays
|
|
|
|
|
if (i + batchSize < relays.length) { |
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100)) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
allEvents.push(...recentEvents) |
|
|
|
|
|
|
|
|
|
// 2. Fetch events from bookmark/pin lists
|
|
|
|
|
// 2. Fetch events from bookmark/pin lists (with rate limiting)
|
|
|
|
|
if (listEventIds.length > 0) { |
|
|
|
|
const bookmarkPinEvents = await client.fetchEvents(relays, { |
|
|
|
|
ids: listEventIds, |
|
|
|
|
limit: 500 |
|
|
|
|
}) |
|
|
|
|
console.log('[TrendingNotes] Fetched', bookmarkPinEvents.length, 'events from bookmark/pin lists') |
|
|
|
|
allEvents.push(...bookmarkPinEvents) |
|
|
|
|
try { |
|
|
|
|
const bookmarkPinEvents = await client.fetchEvents(relays, { |
|
|
|
|
ids: listEventIds, |
|
|
|
|
limit: 500 |
|
|
|
|
}) |
|
|
|
|
allEvents.push(...bookmarkPinEvents) |
|
|
|
|
} catch (error) { |
|
|
|
|
console.warn('[TrendingNotes] Error fetching bookmark/pin events:', error) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 3. Fetch pin list if user is logged in
|
|
|
|
|
@ -160,12 +285,15 @@ export default function TrendingNotes() {
@@ -160,12 +285,15 @@ export default function TrendingNotes() {
|
|
|
|
|
.map(tag => tag[1]) |
|
|
|
|
|
|
|
|
|
if (pinEventIds.length > 0) { |
|
|
|
|
const pinEvents = await client.fetchEvents(relays, { |
|
|
|
|
ids: pinEventIds, |
|
|
|
|
limit: 500 |
|
|
|
|
}) |
|
|
|
|
console.log('[TrendingNotes] Fetched', pinEvents.length, 'events from pin list') |
|
|
|
|
allEvents.push(...pinEvents) |
|
|
|
|
try { |
|
|
|
|
const pinEvents = await client.fetchEvents(relays, { |
|
|
|
|
ids: pinEventIds, |
|
|
|
|
limit: 500 |
|
|
|
|
}) |
|
|
|
|
allEvents.push(...pinEvents) |
|
|
|
|
} catch (error) { |
|
|
|
|
console.warn('[TrendingNotes] Error fetching pin events:', error) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} catch (error) { |
|
|
|
|
@ -178,26 +306,60 @@ export default function TrendingNotes() {
@@ -178,26 +306,60 @@ export default function TrendingNotes() {
|
|
|
|
|
const eTags = event.tags.filter(t => t[0] === 'e') |
|
|
|
|
return eTags.length === 0 |
|
|
|
|
}) |
|
|
|
|
console.log('[TrendingNotes] After filtering for top-level posts:', topLevelEvents.length, 'events') |
|
|
|
|
|
|
|
|
|
// Fetch stats for events in batches
|
|
|
|
|
const eventsNeedingStats = topLevelEvents.filter(event => !noteStatsService.getNoteStats(event.id)) |
|
|
|
|
// Filter out NSFW content and content warnings
|
|
|
|
|
const filteredEvents = topLevelEvents.filter(event => { |
|
|
|
|
// Check for NSFW in 't' tags
|
|
|
|
|
const hasNsfwTag = event.tags.some(tag =>
|
|
|
|
|
tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'nsfw' |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
// Check for sensitive content tag
|
|
|
|
|
const hasSensitiveTag = event.tags.some(tag =>
|
|
|
|
|
tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'sensitive' |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
// Check for #NSFW hashtag in content
|
|
|
|
|
const hasNsfwHashtag = event.content.toLowerCase().includes('#nsfw') |
|
|
|
|
|
|
|
|
|
// Check for content-warning tag (NIP-36)
|
|
|
|
|
const hasContentWarning = event.tags.some(tag =>
|
|
|
|
|
tag[0] === 'content-warning' |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
// Check for L tag with content-warning namespace
|
|
|
|
|
const hasContentWarningL = event.tags.some(tag =>
|
|
|
|
|
tag[0] === 'L' && tag[1] && tag[1].toLowerCase() === 'content-warning' |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
// Check for l tag with content-warning namespace
|
|
|
|
|
const hasContentWarningl = event.tags.some(tag =>
|
|
|
|
|
tag[0] === 'l' && tag[1] && tag[1].toLowerCase() === 'content-warning' |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
// Filter out if any NSFW or content warning indicators are found
|
|
|
|
|
return !hasNsfwTag && !hasSensitiveTag && !hasNsfwHashtag &&
|
|
|
|
|
!hasContentWarning && !hasContentWarningL && !hasContentWarningl |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
// Fetch stats for events in batches with longer delays
|
|
|
|
|
const eventsNeedingStats = filteredEvents.filter(event => !noteStatsService.getNoteStats(event.id)) |
|
|
|
|
|
|
|
|
|
if (eventsNeedingStats.length > 0) { |
|
|
|
|
const batchSize = 10 |
|
|
|
|
const batchSize = 5 // Reduced batch size
|
|
|
|
|
for (let i = 0; i < eventsNeedingStats.length; i += batchSize) { |
|
|
|
|
const batch = eventsNeedingStats.slice(i, i + batchSize) |
|
|
|
|
await Promise.all(batch.map(event =>
|
|
|
|
|
noteStatsService.fetchNoteStats(event, undefined).catch(() => {}) |
|
|
|
|
)) |
|
|
|
|
if (i + batchSize < eventsNeedingStats.length) { |
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 200)) |
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 500)) // Increased delay
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Score events
|
|
|
|
|
const scoredEvents = topLevelEvents.map((event) => { |
|
|
|
|
const scoredEvents = filteredEvents.map((event) => { |
|
|
|
|
const stats = noteStatsService.getNoteStats(event.id) |
|
|
|
|
let score = 0 |
|
|
|
|
|
|
|
|
|
@ -225,11 +387,22 @@ export default function TrendingNotes() {
@@ -225,11 +387,22 @@ export default function TrendingNotes() {
|
|
|
|
|
cachedCustomEvents = { |
|
|
|
|
events: scoredEvents, |
|
|
|
|
timestamp: now, |
|
|
|
|
hashtags: hashtags.slice(), |
|
|
|
|
hashtags: [], |
|
|
|
|
listEventIds: listEventIds.slice() |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
console.log('[TrendingNotes] Cache initialized with', scoredEvents.length, 'events') |
|
|
|
|
|
|
|
|
|
// For hashtag analysis, we want ALL events with hashtags, not just trending ones
|
|
|
|
|
// So we'll store the unfiltered events that have hashtags
|
|
|
|
|
const eventsWithHashtags = filteredEvents.filter(event => { |
|
|
|
|
const eventHashtags = event.tags |
|
|
|
|
.filter(tag => tag[0] === 't' && tag[1]) |
|
|
|
|
.map(tag => tag[1].toLowerCase()) |
|
|
|
|
const contentHashtags = event.content.match(/#[a-zA-Z0-9_]+/g)?.map(h => h.slice(1).toLowerCase()) || [] |
|
|
|
|
return eventHashtags.length > 0 || contentHashtags.length > 0 |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
setCacheEvents(eventsWithHashtags) |
|
|
|
|
} catch (error) { |
|
|
|
|
console.error('[TrendingNotes] Error initializing cache:', error) |
|
|
|
|
} finally { |
|
|
|
|
@ -244,11 +417,46 @@ export default function TrendingNotes() {
@@ -244,11 +417,46 @@ export default function TrendingNotes() {
|
|
|
|
|
|
|
|
|
|
const filteredEvents = useMemo(() => { |
|
|
|
|
const idSet = new Set<string>() |
|
|
|
|
|
|
|
|
|
return trendingNotes.slice(0, showCount).filter((evt) => { |
|
|
|
|
|
|
|
|
|
// Use appropriate data source based on tab and filter
|
|
|
|
|
let sourceEvents = trendingNotes |
|
|
|
|
|
|
|
|
|
if (activeTab === 'hashtags') { |
|
|
|
|
// Always use cache events for hashtags tab - this contains ALL events with hashtags
|
|
|
|
|
sourceEvents = cacheEvents |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let filtered = sourceEvents.filter((evt) => { |
|
|
|
|
if (isEventDeleted(evt)) return false |
|
|
|
|
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return false |
|
|
|
|
|
|
|
|
|
// Filter based on active tab
|
|
|
|
|
if (activeTab === 'hashtags') { |
|
|
|
|
if (hashtagFilter === 'popular') { |
|
|
|
|
// Check if event has any hashtags (either in 't' tags or content)
|
|
|
|
|
const eventHashtags = evt.tags |
|
|
|
|
.filter(tag => tag[0] === 't' && tag[1]) |
|
|
|
|
.map(tag => tag[1].toLowerCase()) |
|
|
|
|
const contentHashtags = evt.content.match(/#[a-zA-Z0-9_]+/g)?.map(h => h.slice(1).toLowerCase()) || [] |
|
|
|
|
const allHashtags = [...eventHashtags, ...contentHashtags] |
|
|
|
|
|
|
|
|
|
// Only show events that have at least one hashtag
|
|
|
|
|
if (allHashtags.length === 0) return false |
|
|
|
|
|
|
|
|
|
if (selectedHashtag) { |
|
|
|
|
// Filter by selected popular hashtag - only show events that contain this specific hashtag
|
|
|
|
|
if (!allHashtags.includes(selectedHashtag.toLowerCase())) return false |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} else if (activeTab === 'relays') { |
|
|
|
|
// For "on your relays" tab, we'll show all events (they're already from user's relays)
|
|
|
|
|
// This is the default behavior, so no additional filtering needed
|
|
|
|
|
} else if (activeTab === 'band') { |
|
|
|
|
// For "on Band" tab, we'll show all events (this is the general trending)
|
|
|
|
|
// This is the default behavior, so no additional filtering needed
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id |
|
|
|
|
if (idSet.has(id)) { |
|
|
|
|
return false |
|
|
|
|
@ -256,19 +464,127 @@ export default function TrendingNotes() {
@@ -256,19 +464,127 @@ export default function TrendingNotes() {
|
|
|
|
|
idSet.add(id) |
|
|
|
|
return true |
|
|
|
|
}) |
|
|
|
|
}, [trendingNotes, hideUntrustedNotes, showCount, isEventDeleted]) |
|
|
|
|
|
|
|
|
|
// Apply sorting
|
|
|
|
|
filtered.sort((a, b) => { |
|
|
|
|
if (sortOrder === 'newest') { |
|
|
|
|
return b.created_at - a.created_at |
|
|
|
|
} else if (sortOrder === 'oldest') { |
|
|
|
|
return a.created_at - b.created_at |
|
|
|
|
} else if (sortOrder === 'most-popular' || sortOrder === 'least-popular') { |
|
|
|
|
const statsA = noteStatsService.getNoteStats(a.id) |
|
|
|
|
const statsB = noteStatsService.getNoteStats(b.id) |
|
|
|
|
|
|
|
|
|
let scoreA = 0 |
|
|
|
|
let scoreB = 0 |
|
|
|
|
|
|
|
|
|
if (statsA) { |
|
|
|
|
scoreA += (statsA.likes?.length || 0) |
|
|
|
|
scoreA += (statsA.replies?.length || 0) * 3 |
|
|
|
|
scoreA += (statsA.reposts?.length || 0) * 5 |
|
|
|
|
scoreA += (statsA.quotes?.length || 0) * 8 |
|
|
|
|
scoreA += (statsA.highlights?.length || 0) * 10 |
|
|
|
|
if (statsA.zaps) { |
|
|
|
|
statsA.zaps.forEach(zap => { |
|
|
|
|
scoreA += zap.amount >= zapReplyThreshold ? 8 : 1 |
|
|
|
|
}) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (statsB) { |
|
|
|
|
scoreB += (statsB.likes?.length || 0) |
|
|
|
|
scoreB += (statsB.replies?.length || 0) * 3 |
|
|
|
|
scoreB += (statsB.reposts?.length || 0) * 5 |
|
|
|
|
scoreB += (statsB.quotes?.length || 0) * 8 |
|
|
|
|
scoreB += (statsB.highlights?.length || 0) * 10 |
|
|
|
|
if (statsB.zaps) { |
|
|
|
|
statsB.zaps.forEach(zap => { |
|
|
|
|
scoreB += zap.amount >= zapReplyThreshold ? 8 : 1 |
|
|
|
|
}) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return sortOrder === 'most-popular' ? scoreB - scoreA : scoreA - scoreB |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return 0 |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
return filtered.slice(0, showCount) |
|
|
|
|
}, [trendingNotes, hideUntrustedNotes, showCount, isEventDeleted, activeTab, listEventIds, bookmarkFilter, followsBookmarkEventIds, hashtagFilter, selectedHashtag, sortOrder, zapReplyThreshold, cacheEvents]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
const fetchTrendingPosts = async () => { |
|
|
|
|
setLoading(true) |
|
|
|
|
const events = await client.fetchTrendingNotes() |
|
|
|
|
setTrendingNotes(events) |
|
|
|
|
|
|
|
|
|
// Apply the same NSFW and content warning filtering
|
|
|
|
|
const filteredEvents = events.filter(event => { |
|
|
|
|
// Check for NSFW in 't' tags
|
|
|
|
|
const hasNsfwTag = event.tags.some(tag =>
|
|
|
|
|
tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'nsfw' |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
// Check for sensitive content tag
|
|
|
|
|
const hasSensitiveTag = event.tags.some(tag =>
|
|
|
|
|
tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'sensitive' |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
// Check for #NSFW hashtag in content
|
|
|
|
|
const hasNsfwHashtag = event.content.toLowerCase().includes('#nsfw') |
|
|
|
|
|
|
|
|
|
// Check for content-warning tag (NIP-36)
|
|
|
|
|
const hasContentWarning = event.tags.some(tag =>
|
|
|
|
|
tag[0] === 'content-warning' |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
// Check for L tag with content-warning namespace
|
|
|
|
|
const hasContentWarningL = event.tags.some(tag =>
|
|
|
|
|
tag[0] === 'L' && tag[1] && tag[1].toLowerCase() === 'content-warning' |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
// Check for l tag with content-warning namespace
|
|
|
|
|
const hasContentWarningl = event.tags.some(tag =>
|
|
|
|
|
tag[0] === 'l' && tag[1] && tag[1].toLowerCase() === 'content-warning' |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
// Filter out if any NSFW or content warning indicators are found
|
|
|
|
|
return !hasNsfwTag && !hasSensitiveTag && !hasNsfwHashtag &&
|
|
|
|
|
!hasContentWarning && !hasContentWarningL && !hasContentWarningl |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
setTrendingNotes(filteredEvents) |
|
|
|
|
setLoading(false) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fetchTrendingPosts() |
|
|
|
|
}, []) |
|
|
|
|
|
|
|
|
|
// Reset showCount when tab changes
|
|
|
|
|
useEffect(() => { |
|
|
|
|
setShowCount(10) |
|
|
|
|
}, [activeTab]) |
|
|
|
|
|
|
|
|
|
// Reset filters when switching tabs
|
|
|
|
|
useEffect(() => { |
|
|
|
|
if (activeTab === 'band') { |
|
|
|
|
setSortOrder('most-popular') |
|
|
|
|
} else if (activeTab === 'relays') { |
|
|
|
|
setSortOrder('most-popular') |
|
|
|
|
} else if (activeTab === 'hashtags') { |
|
|
|
|
setSortOrder('most-popular') |
|
|
|
|
setSelectedHashtag(null) |
|
|
|
|
} |
|
|
|
|
}, [activeTab, pubkey]) |
|
|
|
|
|
|
|
|
|
// Handle case where bookmarks tab is not available
|
|
|
|
|
useEffect(() => { |
|
|
|
|
if (!pubkey && activeTab === 'bookmarks') { |
|
|
|
|
setActiveTab('band') |
|
|
|
|
} |
|
|
|
|
}, [pubkey, activeTab]) |
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
if (showCount >= trendingNotes.length) return |
|
|
|
|
|
|
|
|
|
@ -299,8 +615,125 @@ export default function TrendingNotes() {
@@ -299,8 +615,125 @@ export default function TrendingNotes() {
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<div className="min-h-screen"> |
|
|
|
|
<div className="sticky top-12 h-12 px-4 flex flex-col justify-center text-lg font-bold bg-background z-30 border-b"> |
|
|
|
|
{t('Trending Notes')} |
|
|
|
|
<div className="sticky top-12 bg-background z-30 border-b"> |
|
|
|
|
<div className="h-12 px-4 flex flex-col justify-center text-lg font-bold"> |
|
|
|
|
{t('Trending Notes')} |
|
|
|
|
</div> |
|
|
|
|
<div className="flex items-center gap-2 px-4 pb-2"> |
|
|
|
|
<span className="text-sm font-medium text-muted-foreground">Trending:</span> |
|
|
|
|
<div className="flex gap-1"> |
|
|
|
|
<button |
|
|
|
|
onClick={() => setActiveTab('band')} |
|
|
|
|
className={`px-3 py-1 text-sm rounded-md transition-colors ${ |
|
|
|
|
activeTab === 'band' |
|
|
|
|
? 'bg-primary text-primary-foreground' |
|
|
|
|
: 'bg-muted hover:bg-muted/80 text-muted-foreground' |
|
|
|
|
}`}
|
|
|
|
|
> |
|
|
|
|
on Band |
|
|
|
|
</button> |
|
|
|
|
<button |
|
|
|
|
onClick={() => setActiveTab('relays')} |
|
|
|
|
className={`px-3 py-1 text-sm rounded-md transition-colors ${ |
|
|
|
|
activeTab === 'relays' |
|
|
|
|
? 'bg-primary text-primary-foreground' |
|
|
|
|
: 'bg-muted hover:bg-muted/80 text-muted-foreground' |
|
|
|
|
}`}
|
|
|
|
|
> |
|
|
|
|
on your relays |
|
|
|
|
</button> |
|
|
|
|
<button |
|
|
|
|
onClick={() => setActiveTab('hashtags')} |
|
|
|
|
className={`px-3 py-1 text-sm rounded-md transition-colors ${ |
|
|
|
|
activeTab === 'hashtags' |
|
|
|
|
? 'bg-primary text-primary-foreground' |
|
|
|
|
: 'bg-muted hover:bg-muted/80 text-muted-foreground' |
|
|
|
|
}`}
|
|
|
|
|
> |
|
|
|
|
hashtags |
|
|
|
|
</button> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
{/* Second row controls for tabs 2-3 */} |
|
|
|
|
{(activeTab === 'relays' || activeTab === 'hashtags') && ( |
|
|
|
|
<div className="flex items-center gap-4 px-4 pb-2"> |
|
|
|
|
{/* Sorting controls - not shown for hashtags tab */} |
|
|
|
|
{activeTab !== 'hashtags' && ( |
|
|
|
|
<div className="flex items-center gap-2"> |
|
|
|
|
<span className="text-xs text-muted-foreground">Sort:</span> |
|
|
|
|
<div className="flex gap-1"> |
|
|
|
|
<button |
|
|
|
|
onClick={() => setSortOrder('newest')} |
|
|
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${ |
|
|
|
|
sortOrder === 'newest' |
|
|
|
|
? 'bg-secondary text-secondary-foreground' |
|
|
|
|
: 'bg-muted/50 hover:bg-muted text-muted-foreground' |
|
|
|
|
}`}
|
|
|
|
|
> |
|
|
|
|
newest |
|
|
|
|
</button> |
|
|
|
|
<button |
|
|
|
|
onClick={() => setSortOrder('oldest')} |
|
|
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${ |
|
|
|
|
sortOrder === 'oldest' |
|
|
|
|
? 'bg-secondary text-secondary-foreground' |
|
|
|
|
: 'bg-muted/50 hover:bg-muted text-muted-foreground' |
|
|
|
|
}`}
|
|
|
|
|
> |
|
|
|
|
oldest |
|
|
|
|
</button> |
|
|
|
|
<button |
|
|
|
|
onClick={() => setSortOrder('most-popular')} |
|
|
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${ |
|
|
|
|
sortOrder === 'most-popular' |
|
|
|
|
? 'bg-secondary text-secondary-foreground' |
|
|
|
|
: 'bg-muted/50 hover:bg-muted text-muted-foreground' |
|
|
|
|
}`}
|
|
|
|
|
> |
|
|
|
|
most popular |
|
|
|
|
</button> |
|
|
|
|
<button |
|
|
|
|
onClick={() => setSortOrder('least-popular')} |
|
|
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${ |
|
|
|
|
sortOrder === 'least-popular' |
|
|
|
|
? 'bg-secondary text-secondary-foreground' |
|
|
|
|
: 'bg-muted/50 hover:bg-muted text-muted-foreground' |
|
|
|
|
}`}
|
|
|
|
|
> |
|
|
|
|
least popular |
|
|
|
|
</button> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
</div> |
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Popular hashtag buttons for hashtags tab */} |
|
|
|
|
{activeTab === 'hashtags' && hashtagFilter === 'popular' && popularHashtags.length > 0 && ( |
|
|
|
|
<div className="flex items-center gap-2 px-4 pb-2"> |
|
|
|
|
<span className="text-xs text-muted-foreground">Popular hashtags:</span> |
|
|
|
|
<div className="flex gap-1 flex-wrap"> |
|
|
|
|
{popularHashtags.map((hashtag) => ( |
|
|
|
|
<button |
|
|
|
|
key={hashtag} |
|
|
|
|
onClick={() => setSelectedHashtag(selectedHashtag === hashtag ? null : hashtag)} |
|
|
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${ |
|
|
|
|
selectedHashtag === hashtag |
|
|
|
|
? 'bg-primary text-primary-foreground' |
|
|
|
|
: 'bg-muted/50 hover:bg-muted text-muted-foreground' |
|
|
|
|
}`}
|
|
|
|
|
> |
|
|
|
|
#{hashtag} |
|
|
|
|
</button> |
|
|
|
|
))} |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
)} |
|
|
|
|
</div> |
|
|
|
|
{filteredEvents.map((event) => ( |
|
|
|
|
<NoteCard key={event.id} className="w-full" event={event} /> |
|
|
|
|
|