You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

422 lines
15 KiB

import { Favicon } from '@/components/Favicon'
import Nip05DomainEmptyState from '@/components/Nip05DomainPanel/Nip05DomainEmptyState'
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,
NIP_SEARCH_PAGE_KINDS,
SEARCHABLE_RELAY_URLS
} from '@/constants'
import {
augmentSubRequestsWithFavoritesFastReadAndInbox,
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadInboxUrls,
userWriteOutboxUrls
} from '@/lib/favorites-feed-relays'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfileList } from '@/lib/link'
import {
buildAlexandriaEventsUrlForDTagParam,
buildAlexandriaEventsUrlForHashtagParam
} from '@/lib/alexandria-events-search-url'
import { compareEventsForDTagQuery, eventMatchesDTagLooseQuery } from '@/lib/dtag-search'
import { fetchPubkeysFromDomain } 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 { normalizeUrl } from '@/lib/url'
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<HTMLDivElement, NoteListPageProps>(({ index, hideTitlebar = false }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef<TNoteListRef>(null)
const bumpFeed = useCallback(() => feedRef.current?.refresh(), [])
const { push } = useSecondaryPage()
const { relayList, cacheRelayListEvent, pubkey } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const interestList = useInterestListOptional()
const isSubscribed = interestList?.isSubscribed ?? (() => false)
const subscribe = interestList?.subscribe ?? (async () => {})
const [title, setTitle] = useState<React.ReactNode>(null)
const [controls, setControls] = useState<React.ReactNode>(null)
const [data, setData] = useState<
| {
type: 'hashtag' | 'hashtagSearch' | 'search' | 'externalContent' | 'dtag'
kinds?: number[]
dtag?: string
}
| {
type: 'domain'
domain: string
kinds?: number[]
}
| null
>(null)
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const alexandriaEmptyUrl = useMemo(() => {
if (!data) return null
if (data.type === 'dtag' && data.dtag) return buildAlexandriaEventsUrlForDTagParam(data.dtag)
if (data.type === 'hashtag' || data.type === 'hashtagSearch') {
const t = new URLSearchParams(window.location.search).get('t') ?? ''
return buildAlexandriaEventsUrlForHashtagParam(t)
}
return null
}, [data])
// Get hashtag from URL if this is a hashtag page
const hashtag = useMemo(() => {
if (data?.type === 'hashtag' || data?.type === 'hashtagSearch') {
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: userWriteOutboxUrls(relayList, cacheRelayListEvent),
applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind),
useGlobalFavoriteDefaults: useGlobalRelayBootstrap,
includeGlobalFastRead: useGlobalRelayBootstrap
}
const hashtag = searchParams.get('t')
const searchFromUrl = searchParams.get('s')
if (hashtag && searchFromUrl) {
setData({ type: 'hashtagSearch' })
setTitle(`${t('Search')}: #${hashtag} · ${searchFromUrl}`)
const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadInboxUrls(relayList, cacheRelayListEvent),
readUrlOpts
)
const mergedSearchKinds = Array.from(
new Set<number>([...NIP_SEARCH_PAGE_KINDS, ...(kinds.length > 0 ? kinds : [])])
).sort((a, b) => a - b)
setSubRequests([
{
filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) },
urls: relayUrls
},
{
filter: { search: searchFromUrl, kinds: mergedSearchKinds },
urls: [...new Set([...relayUrls, ...SEARCHABLE_RELAY_URLS])]
}
])
const isSubscribedToHashtag = isSubscribed(hashtag)
if (pubkey) {
setControls(
<Button
variant="ghost"
className="h-10 [&_svg]:size-3"
onClick={handleSubscribeHashtag}
disabled={isSubscribedToHashtag}
>
{isSubscribedToHashtag ? t('Subscribed') : t('Subscribe')} <Plus />
</Button>
)
} else {
setControls(null)
}
return
}
if (hashtag) {
setData({ type: 'hashtag' })
setTitle(`# ${hashtag}`)
setSubRequests([
{
filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) },
urls: getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadInboxUrls(relayList, cacheRelayListEvent),
readUrlOpts
)
}
])
// Set controls for hashtag subscribe button - check subscription status
const isSubscribedToHashtag = isSubscribed(hashtag)
if (pubkey) {
setControls(
<Button
variant="ghost"
className="h-10 [&_svg]:size-3"
onClick={handleSubscribeHashtag}
disabled={isSubscribedToHashtag}
>
{isSubscribedToHashtag ? t('Subscribed') : t('Subscribe')} <Plus />
</Button>
)
}
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,
userReadInboxUrls(relayList, cacheRelayListEvent),
{ userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), useGlobalFavoriteDefaults: useGlobalRelayBootstrap, includeGlobalFastRead: useGlobalRelayBootstrap }
)
}
])
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(
<div className="flex items-center gap-1">
{domain}
<Favicon domain={domain} className="w-5 h-5" />
</div>
)
const pubkeys = await fetchPubkeysFromDomain(domain)
setData({
type: 'domain',
domain
})
if (pubkeys.length) {
const raw = await client.generateSubRequestsForPubkeys(pubkeys, pubkey)
setSubRequests(
augmentSubRequestsWithFavoritesFastReadAndInbox(
raw,
favoriteRelays,
blockedRelays,
userReadInboxUrls(relayList, cacheRelayListEvent),
{
userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent),
useGlobalFavoriteDefaults: useGlobalRelayBootstrap,
includeGlobalFastRead: useGlobalRelayBootstrap
}
)
)
setControls(
<Button
variant="ghost"
className="h-10 [&_svg]:size-3"
onClick={() => push(toProfileList({ domain }))}
>
{pubkeys.length.toLocaleString()} <UserRound />
</Button>
)
} else {
setSubRequests([])
}
} else {
// D-tag browse: exact `#d` REQ on index + user relays (no NIP-50 full-text — that is not the same as a d-tag pick).
setTitle(`D-Tag: ${domain}`)
setData({
type: 'dtag',
dtag: domain,
kinds: kinds.length > 0 ? kinds : undefined
})
const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadInboxUrls(relayList, cacheRelayListEvent),
readUrlOpts
)
const mergedReqKinds = Array.from(
new Set([...NIP_SEARCH_DOCUMENT_KINDS, ...(kinds.length > 0 ? kinds : [])])
).sort((a, b) => a - b)
const kindFilter = { kinds: mergedReqKinds }
const dUrls = [...new Set([...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean), ...relayUrls])]
setSubRequests([
{
filter: { '#d': [domain], ...kindFilter },
urls: dUrls
}
])
}
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,
useGlobalRelayBootstrap
])
// 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' || data?.type === 'hashtagSearch') && pubkey) {
setControls(
<Button
variant="ghost"
className="h-10 [&_svg]:size-3"
onClick={handleSubscribeHashtag}
disabled={isHashtagSubscribed}
>
{isHashtagSubscribed ? t('Subscribed') : t('Subscribe')} <Plus />
</Button>
)
}
}, [data, pubkey, isHashtagSubscribed, handleSubscribeHashtag, t])
useEffect(() => {
const inlineHeader =
hideTitlebar &&
(data?.type === 'hashtag' || data?.type === 'hashtagSearch' || 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 = <Nip05DomainEmptyState domain={data.domain} />
} else if (data) {
content =
data.type === 'dtag' && data.dtag ? (
<NormalFeed
ref={feedRef}
subRequests={subRequests}
oneShotFetch
progressiveWarmupQuery={data.dtag}
progressiveWarmupMatch={(ev) => 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}
alexandriaEmptyUrl={alexandriaEmptyUrl}
/>
) : (
<NormalFeed ref={feedRef} subRequests={subRequests} alexandriaEmptyUrl={alexandriaEmptyUrl} />
)
}
const titlebarExtras = controls
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : title}
controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-1">
<RefreshButton onClick={bumpFeed} />
{titlebarExtras}
</div>
)
}
displayScrollToTopButton
>
{hideTitlebar &&
(data?.type === 'hashtag' || data?.type === 'hashtagSearch' || data?.type === 'dtag') ? (
<>
<div className="px-4 py-2 border-b">
<div className="flex items-center justify-between gap-2">
<div className="app-chrome-title">{title}</div>
<div className="flex items-center gap-1">
<RefreshButton onClick={bumpFeed} />
{titlebarExtras}
</div>
</div>
</div>
<div className="pt-4">{content}</div>
</>
) : (
content
)}
</SecondaryPageLayout>
)
})
NoteListPage.displayName = 'NoteListPage'
export default NoteListPage