import { Favicon } from '@/components/Favicon' import type { TNoteListRef } from '@/components/NoteList' import NormalFeed from '@/components/NormalFeed' import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' import { isSocialKindBlockedKind, NIP_SEARCH_DOCUMENT_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' import { augmentSubRequestsWithFavoritesFastReadAndInbox, getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { toProfileList } from '@/lib/link' import { compareEventsForDTagQuery, eventMatchesDTagLooseQuery } from '@/lib/dtag-search' import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { useSecondaryPage } from '@/PageManager' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import { useInterestListOptional } from '@/providers/interest-list-context' import client from '@/services/client.service' import { TFeedSubRequest } from '@/types' import { UserRound, Plus } from 'lucide-react' import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' interface NoteListPageProps { index?: number hideTitlebar?: boolean } const NoteListPage = forwardRef(({ index, hideTitlebar = false }, ref) => { const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const feedRef = useRef(null) const bumpFeed = useCallback(() => feedRef.current?.refresh(), []) const { push } = useSecondaryPage() const { relayList, pubkey } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const interestList = useInterestListOptional() const isSubscribed = interestList?.isSubscribed ?? (() => false) const subscribe = interestList?.subscribe ?? (async () => {}) const [title, setTitle] = useState(null) const [controls, setControls] = useState(null) const [data, setData] = useState< | { type: 'hashtag' | 'search' | 'externalContent' | 'dtag' kinds?: number[] dtag?: string } | { type: 'domain' domain: string kinds?: number[] } | null >(null) const [subRequests, setSubRequests] = useState([]) // Get hashtag from URL if this is a hashtag page const hashtag = useMemo(() => { if (data?.type === 'hashtag') { const searchParams = new URLSearchParams(window.location.search) return searchParams.get('t') } return null }, [data]) // Check if the hashtag is already in the user's interest list const isHashtagSubscribed = useMemo(() => { if (!hashtag) return false return isSubscribed(hashtag) }, [hashtag, isSubscribed]) // Add hashtag to interest list - wrapped in useCallback to prevent circular dependencies const handleSubscribeHashtag = useCallback(async () => { const searchParams = new URLSearchParams(window.location.search) const hashtag = searchParams.get('t') if (!hashtag) return await subscribe(hashtag) }, [subscribe]) // Extract initialization logic into a reusable function const initializeFromUrl = useCallback(async () => { const searchParams = new URLSearchParams(window.location.search) const kinds = searchParams .getAll('k') .map((k) => parseInt(k)) .filter((k) => !isNaN(k)) const readUrlOpts = { userWriteRelays: relayList?.write ?? [], applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind) } const hashtag = searchParams.get('t') if (hashtag) { setData({ type: 'hashtag' }) setTitle(`# ${hashtag}`) setSubRequests([ { filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) }, urls: getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), readUrlOpts ) } ]) // Set controls for hashtag subscribe button - check subscription status const isSubscribedToHashtag = isSubscribed(hashtag) if (pubkey) { setControls( ) } return } const search = searchParams.get('s') if (search) { setData({ type: 'search' }) setTitle(`${t('Search')}: ${search}`) setSubRequests([ { filter: { search, ...(kinds.length > 0 ? { kinds } : {}) }, urls: SEARCHABLE_RELAY_URLS } ]) return } const externalContentId = searchParams.get('i') if (externalContentId) { setData({ type: 'externalContent' }) setTitle(externalContentId) setSubRequests([ { filter: { '#I': [externalContentId], ...(kinds.length > 0 ? { kinds } : {}) }, urls: getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] } ) } ]) return } const domain = searchParams.get('d') if (domain) { // Check if it looks like a domain (contains a dot) or is a d-tag search const looksLikeDomain = domain.includes('.') if (looksLikeDomain) { // Domain lookup (NIP-05) setTitle(
{domain}
) const pubkeys = await fetchPubkeysFromDomain(domain) setData({ type: 'domain', domain }) if (pubkeys.length) { const raw = await client.generateSubRequestsForPubkeys(pubkeys, pubkey) setSubRequests( augmentSubRequestsWithFavoritesFastReadAndInbox( raw, favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] } ) ) setControls( ) } else { setSubRequests([]) } } else { // D-tag browse: NIP-50 search + exact #d REQ (merged), substring match client-side, exact d-tag sorted first setTitle(`D-Tag: ${domain}`) setData({ type: 'dtag', dtag: domain, kinds: kinds.length > 0 ? kinds : undefined }) const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), readUrlOpts ) const mergedReqKinds = Array.from( new Set([...NIP_SEARCH_DOCUMENT_KINDS, ...(kinds.length > 0 ? kinds : [])]) ).sort((a, b) => a - b) const kindFilter = { kinds: mergedReqKinds } // NIP-50 full-text search works better with natural-language spacing; // convert the hyphenated slug back to a space-separated query for the search relay. const searchQuery = domain.replace(/-/g, ' ') setSubRequests([ { filter: { search: searchQuery, ...kindFilter }, urls: [...new Set([...relayUrls, ...SEARCHABLE_RELAY_URLS])] }, { filter: { '#d': [domain], ...kindFilter }, urls: relayUrls } ]) } return } // Advanced search parameters removed // Note: Only hashtag (t=) and kind (k=) URL parameters are supported // Date searches, pubkey filters, and event filters removed - not supported }, [ pubkey, relayList, favoriteRelays, blockedRelays, handleSubscribeHashtag, push, t, isSubscribed, subscribe, client ]) // Initialize on mount useEffect(() => { initializeFromUrl() }, [initializeFromUrl]) // Listen for URL changes to re-initialize the page useEffect(() => { const handleLocationChange = () => { initializeFromUrl() } // Listen for browser back/forward navigation window.addEventListener('popstate', handleLocationChange) // Listen for custom hashtag navigation events window.addEventListener('hashtag-navigation', handleLocationChange) return () => { window.removeEventListener('popstate', handleLocationChange) window.removeEventListener('hashtag-navigation', handleLocationChange) } }, [initializeFromUrl]) // Update controls when subscription status changes useEffect(() => { if (data?.type === 'hashtag' && pubkey) { setControls( ) } }, [data, pubkey, isHashtagSubscribed, handleSubscribeHashtag, t]) useEffect(() => { const inlineHeader = hideTitlebar && (data?.type === 'hashtag' || data?.type === 'dtag') if (!hideTitlebar || inlineHeader) { registerPrimaryPanelRefresh(null) return } registerPrimaryPanelRefresh(bumpFeed) return () => registerPrimaryPanelRefresh(null) }, [hideTitlebar, data?.type, registerPrimaryPanelRefresh, bumpFeed]) let content: React.ReactNode = null if (data?.type === 'domain' && subRequests.length === 0) { content = (
{t('No pubkeys found from {url}', { url: getWellKnownNip05Url(data.domain) })}
) } else if (data) { content = data.type === 'dtag' && data.dtag ? ( eventMatchesDTagLooseQuery(data.dtag!, ev)} progressiveDocumentKinds={NIP_SEARCH_DOCUMENT_KINDS} oneShotAfterMergeComparator={(a, b) => compareEventsForDTagQuery(data.dtag!, a, b)} extraShouldHideEvent={(ev) => !eventMatchesDTagLooseQuery(data.dtag!, ev)} oneShotMergedCap={400} /> ) : ( ) } const titlebarExtras = controls return ( {titlebarExtras} ) } displayScrollToTopButton > {hideTitlebar && (data?.type === 'hashtag' || data?.type === 'dtag') ? ( <>
{title}
{titlebarExtras}
{content}
) : ( content )}
) }) NoteListPage.displayName = 'NoteListPage' export default NoteListPage