feat: nip05 feeds

This commit is contained in:
codytseng
2025-06-26 23:21:12 +08:00
parent e08172f4a7
commit 5619905ae0
28 changed files with 395 additions and 165 deletions

View File

@@ -1,47 +1,13 @@
import UserItem from '@/components/UserItem'
import ProfileList from '@/components/ProfileList'
import { useFetchFollowings, useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef, useEffect, useRef, useState } from 'react'
import { forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
const FollowingListPage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => {
const { t } = useTranslation()
const { profile } = useFetchProfile(id)
const { followings } = useFetchFollowings(profile?.pubkey)
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setVisibleFollowings(followings.slice(0, 10))
}, [followings])
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 1
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && followings.length > visibleFollowings.length) {
setVisibleFollowings((prev) => [
...prev,
...followings.slice(prev.length, prev.length + 10)
])
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [visibleFollowings, followings])
return (
<SecondaryPageLayout
@@ -54,12 +20,7 @@ const FollowingListPage = forwardRef(({ id, index }: { id?: string; index?: numb
}
displayScrollToTopButton
>
<div className="space-y-2 px-4">
{visibleFollowings.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{followings.length > visibleFollowings.length && <div ref={bottomRef} />}
</div>
<ProfileList pubkeys={followings} />
</SecondaryPageLayout>
)
})

View File

@@ -1,54 +1,125 @@
import { Favicon } from '@/components/Favicon'
import NoteList from '@/components/NoteList'
import { Button } from '@/components/ui/button'
import { BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfileList } from '@/lib/link'
import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { UserRound } from 'lucide-react'
import { Filter } from 'nostr-tools'
import { forwardRef, useMemo } from 'react'
import React, { forwardRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { relayList } = useNostr()
const {
title = '',
filter,
urls
} = useMemo<{
title?: string
filter?: Filter
urls: string[]
}>(() => {
const searchParams = new URLSearchParams(window.location.search)
const hashtag = searchParams.get('t')
if (hashtag) {
return {
title: `# ${hashtag}`,
filter: { '#t': [hashtag] },
urls: BIG_RELAY_URLS
const [title, setTitle] = useState<React.ReactNode>(null)
const [controls, setControls] = useState<React.ReactNode>(null)
const [data, setData] = useState<
| {
type: 'hashtag' | 'search' | 'externalContent'
filter: Filter
urls: string[]
}
| {
type: 'domain'
filter: Filter
domain: string
urls?: string[]
}
| null
>(null)
useEffect(() => {
const init = async () => {
const searchParams = new URLSearchParams(window.location.search)
const hashtag = searchParams.get('t')
if (hashtag) {
setData({
type: 'hashtag',
filter: { '#t': [hashtag] },
urls: BIG_RELAY_URLS
})
setTitle(`# ${hashtag}`)
return
}
const search = searchParams.get('s')
if (search) {
setData({
type: 'search',
filter: { search },
urls: SEARCHABLE_RELAY_URLS
})
setTitle(`${t('Search')}: ${search}`)
return
}
const externalContentId = searchParams.get('i')
if (externalContentId) {
setData({
type: 'externalContent',
filter: { '#I': [externalContentId] },
urls: BIG_RELAY_URLS.concat(relayList?.write || [])
})
setTitle(externalContentId)
return
}
const domain = searchParams.get('d')
if (domain) {
setTitle(
<div className="flex items-center gap-1">
{domain}
<Favicon domain={domain} className="w-5 h-5" />
</div>
)
const pubkeys = await fetchPubkeysFromDomain(domain)
console.log(domain, pubkeys)
setData({
type: 'domain',
domain,
filter: { authors: pubkeys }
})
if (pubkeys.length) {
setControls(
<Button
variant="ghost"
className="h-10 [&_svg]:size-3"
onClick={() => push(toProfileList({ domain }))}
>
{pubkeys.length.toLocaleString()} <UserRound />
</Button>
)
}
return
}
}
const search = searchParams.get('s')
if (search) {
return {
title: `${t('Search')}: ${search}`,
filter: { search },
urls: SEARCHABLE_RELAY_URLS
}
}
const externalContentId = searchParams.get('i')
if (externalContentId) {
return {
title: externalContentId,
filter: { '#I': [externalContentId] },
urls: BIG_RELAY_URLS.concat(relayList?.write || [])
}
}
return { urls: BIG_RELAY_URLS }
init()
}, [])
let content: React.ReactNode = null
if (data?.type === 'domain' && data.filter?.authors?.length === 0) {
content = (
<div className="text-center w-full py-10">
<span className="text-muted-foreground">
{t('No pubkeys found from {url}', { url: getWellKnownNip05Url(data.domain) })}
</span>
</div>
)
} else if (data) {
content = <NoteList filter={data.filter} relayUrls={data.urls} />
}
return (
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton>
<NoteList key={title} filter={filter} relayUrls={urls} />
<SecondaryPageLayout
ref={ref}
index={index}
title={title}
controls={controls}
displayScrollToTopButton
>
{content}
</SecondaryPageLayout>
)
})

View File

@@ -1,11 +1,13 @@
import { Favicon } from '@/components/Favicon'
import ProfileList from '@/components/ProfileList'
import UserItem from '@/components/UserItem'
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { useFetchRelayInfos } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { fetchPubkeysFromDomain } from '@/lib/nip05'
import { useFeed } from '@/providers/FeedProvider'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Filter } from 'nostr-tools'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -13,27 +15,75 @@ const LIMIT = 50
const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const [title, setTitle] = useState<React.ReactNode>()
const [data, setData] = useState<{
type: 'search' | 'domain'
id: string
} | null>(null)
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search)
const search = searchParams.get('s')
if (search) {
setTitle(`${t('Search')}: ${search}`)
setData({ type: 'search', id: search })
return
}
const domain = searchParams.get('d')
if (domain) {
setTitle(
<div className="flex items-center gap-1">
{domain}
<Favicon domain={domain} className="w-5 h-5" />
</div>
)
setData({ type: 'domain', id: domain })
return
}
}, [])
let content: React.ReactNode = null
if (data?.type === 'search') {
content = <ProfileListBySearch search={data.id} />
} else if (data?.type === 'domain') {
content = <ProfileListByDomain domain={data.id} />
}
return (
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton>
{content}
</SecondaryPageLayout>
)
})
ProfileListPage.displayName = 'ProfileListPage'
export default ProfileListPage
function ProfileListByDomain({ domain }: { domain: string }) {
const [pubkeys, setPubkeys] = useState<string[]>([])
useEffect(() => {
const init = async () => {
const _pubkeys = await fetchPubkeysFromDomain(domain)
setPubkeys(_pubkeys)
}
init()
}, [domain])
return <ProfileList pubkeys={pubkeys} />
}
function ProfileListBySearch({ search }: { search: string }) {
const { relayUrls } = useFeed()
const { searchableRelayUrls } = useFetchRelayInfos(relayUrls)
const [until, setUntil] = useState<number>(() => dayjs().unix())
const [hasMore, setHasMore] = useState<boolean>(true)
const [pubkeySet, setPubkeySet] = useState(new Set<string>())
const bottomRef = useRef<HTMLDivElement>(null)
const filter = useMemo(() => {
const f: Filter = { until }
const searchParams = new URLSearchParams(window.location.search)
const search = searchParams.get('s')
if (search) {
f.search = search
}
return f
}, [until])
const filter = { until, search }
const urls = useMemo(() => {
return filter.search ? searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4) : relayUrls
}, [relayUrls, searchableRelayUrls, filter])
const title = useMemo(() => {
return filter.search ? `${t('Search')}: ${filter.search}` : t('All users')
}, [filter])
useEffect(() => {
if (!hasMore) return
@@ -80,15 +130,11 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
}
return (
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton>
<div className="space-y-2 px-4">
{Array.from(pubkeySet).map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{hasMore && <div ref={bottomRef} />}
</div>
</SecondaryPageLayout>
<div className="px-4">
{Array.from(pubkeySet).map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{hasMore && <div ref={bottomRef} />}
</div>
)
})
ProfileListPage.displayName = 'ProfileListPage'
export default ProfileListPage
}