feat: nip05 feeds
This commit is contained in:
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user