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

@@ -0,0 +1,7 @@
{
"names": {
"_": "8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883",
"cody": "8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883",
"cody2": "24462930821b45f530ec0063eca0a6522e5a577856f982fa944df0ef3caf03ab"
}
}

View File

@@ -0,0 +1,15 @@
import { useState } from 'react'
export function Favicon({ domain, className }: { domain: string; className?: string }) {
const [error, setError] = useState(false)
if (error) return null
return (
<img
src={`https://${domain}/favicon.ico`}
alt={domain}
className={className}
onError={() => setError(true)}
/>
)
}

View File

@@ -2,6 +2,22 @@ import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
export function FormattedTimestamp({
timestamp,
short = false,
className
}: {
timestamp: number
short?: boolean
className?: string
}) {
return (
<span className={className}>
<FormattedTimestampContent timestamp={timestamp} short={short} />
</span>
)
}
function FormattedTimestampContent({
timestamp,
short = false
}: {

View File

@@ -1,9 +1,12 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks'
import { useFetchNip05 } from '@/hooks/useFetchNip05'
import { toNoteList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
import { BadgeAlert, BadgeCheck } from 'lucide-react'
import { Favicon } from '../Favicon'
export default function Nip05({ pubkey }: { pubkey: string }) {
export default function Nip05({ pubkey, append }: { pubkey: string; append?: string }) {
const { profile } = useFetchProfile(pubkey)
const { nip05IsVerified, nip05Name, nip05Domain, isFetching } = useFetchNip05(
profile?.nip05,
@@ -13,30 +16,27 @@ export default function Nip05({ pubkey }: { pubkey: string }) {
if (isFetching) {
return (
<div className="flex items-center py-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-16" />
</div>
)
}
if (!profile?.nip05) return null
if (!profile?.nip05 || !nip05Name || !nip05Domain) return null
return (
nip05Name &&
nip05Domain && (
<div className="flex items-center space-x-1 truncate">
{nip05Name !== '_' ? (
<div className="text-sm text-muted-foreground truncate">@{nip05Name}</div>
) : null}
<a
href={`https://${nip05Domain}`}
target="_blank"
className={`flex items-center space-x-1 hover:underline truncate ${nip05IsVerified ? 'text-primary' : 'text-muted-foreground'}`}
rel="noreferrer"
>
{nip05IsVerified ? <BadgeCheck className="size-4" /> : <BadgeAlert className="size-4" />}
<div className="text-sm truncate">{nip05Domain}</div>
</a>
</div>
)
<div className="flex items-center gap-1 truncate" onClick={(e) => e.stopPropagation()}>
{nip05Name !== '_' ? (
<span className="text-sm text-muted-foreground truncate">@{nip05Name}</span>
) : null}
<SecondaryPageLink
to={toNoteList({ domain: nip05Domain })}
className={`flex items-center gap-1 hover:underline truncate [&_svg]:size-3.5 [&_svg]:shrink-0 ${nip05IsVerified ? 'text-primary' : 'text-muted-foreground'}`}
>
{nip05IsVerified ? <BadgeCheck /> : <BadgeAlert />}
<span className="text-sm truncate">{nip05Domain}</span>
</SecondaryPageLink>
<Favicon domain={nip05Domain} className="w-3.5 h-3.5" />
{append && <span className="text-sm text-muted-foreground truncate">{append}</span>}
</div>
)
}

View File

@@ -7,11 +7,13 @@ import {
isSupportedKind
} from '@/lib/event'
import { toNote } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import ImageGallery from '../ImageGallery'
import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions'
import ParentNotePreview from '../ParentNotePreview'
import TranslateButton from '../TranslateButton'
@@ -33,6 +35,7 @@ export default function Note({
hideParentNotePreview?: boolean
}) {
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const parentEventId = useMemo(
() => (hideParentNotePreview ? undefined : getParentEventId(event)),
[event, hideParentNotePreview]
@@ -47,28 +50,33 @@ export default function Note({
<div className={className}>
<div className="flex justify-between items-start gap-2">
<div className="flex items-center space-x-2 flex-1">
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'small' : 'normal'} />
<div
className={`flex-1 w-0 ${size === 'small' ? 'flex space-x-2 items-center overflow-hidden' : ''}`}
>
<div className="flex gap-2 items-center">
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} />
<div className="flex-1 w-0">
<div className="flex gap-2 items-baseline">
<Username
userId={event.pubkey}
className={`font-semibold flex truncate ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
{usingClient && size === 'normal' && (
<div className="text-xs text-muted-foreground shrink-0">using {usingClient}</div>
<span className="text-sm text-muted-foreground shrink-0">using {usingClient}</span>
)}
</div>
<div className="text-xs text-muted-foreground shrink-0">
<FormattedTimestamp timestamp={event.created_at} />
<div className="flex items-baseline gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</div>
</div>
</div>
<div className="flex items-center">
<TranslateButton event={event} className={size === 'normal' ? '' : 'pr-0'} />
{size === 'normal' && <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />}
{size === 'normal' && (
<NoteOptions event={event} className="py-1 shrink-0 [&_svg]:size-5" />
)}
</div>
</div>
{parentEventId && (

View File

@@ -0,0 +1,45 @@
import { useEffect, useRef, useState } from 'react'
import UserItem from '../UserItem'
export default function ProfileList({ pubkeys }: { pubkeys: string[] }) {
const [visiblePubkeys, setVisiblePubkeys] = useState<string[]>([])
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setVisiblePubkeys(pubkeys.slice(0, 10))
}, [pubkeys])
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 1
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && pubkeys.length > visiblePubkeys.length) {
setVisiblePubkeys((prev) => [...prev, ...pubkeys.slice(prev.length, prev.length + 10)])
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [visiblePubkeys, pubkeys])
return (
<div className="px-4">
{visiblePubkeys.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{pubkeys.length > visiblePubkeys.length && <div ref={bottomRef} />}
</div>
)
}

View File

@@ -1,20 +1,23 @@
import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { getUsingClient } from '@/lib/event'
import { toNote } from '@/lib/link'
import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Collapsible from '../Collapsible'
import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions'
import NoteStats from '../NoteStats'
import ParentNotePreview from '../ParentNotePreview'
import TranslateButton from '../TranslateButton'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import TranslateButton from '../TranslateButton'
export default function ReplyNote({
event,
@@ -28,6 +31,7 @@ export default function ReplyNote({
highlight?: boolean
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const { mutePubkeys } = useMuteList()
const [showMuted, setShowMuted] = useState(false)
@@ -35,6 +39,7 @@ export default function ReplyNote({
() => showMuted || !mutePubkeys.includes(event.pubkey),
[showMuted, mutePubkeys, event]
)
const usingClient = useMemo(() => getUsingClient(event), [event])
return (
<div
@@ -43,21 +48,33 @@ export default function ReplyNote({
>
<Collapsible>
<div className="flex space-x-2 items-start px-4 pt-3">
<UserAvatar userId={event.pubkey} className="shrink-0 h-8 w-8" />
<UserAvatar userId={event.pubkey} size="medium" className="shrink-0 mt-1" />
<div className="w-full overflow-hidden">
<div className="flex items-start justify-between gap-2">
<div className="flex gap-2 items-center flex-1 w-0">
<Username
userId={event.pubkey}
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
skeletonClassName="h-3"
/>
<div className="text-xs text-muted-foreground shrink-0">
<FormattedTimestamp timestamp={event.created_at} />
<div className="flex-1 w-0">
<div className="flex gap-1 items-baseline">
<Username
userId={event.pubkey}
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
skeletonClassName="h-3"
/>
{usingClient && (
<span className="text-sm text-muted-foreground shrink-0">
using {usingClient}
</span>
)}
</div>
<div className="flex items-baseline gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</div>
</div>
<div className="flex items-center shrink-0">
<TranslateButton event={event} />
<TranslateButton event={event} className="py-0" />
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div>
</div>

View File

@@ -122,7 +122,7 @@ export default function TranslateButton({
return (
<button
className={cn(
'flex items-center text-muted-foreground hover:text-pink-400 px-2 h-full disabled:text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0 transition-colors',
'flex items-center text-muted-foreground hover:text-pink-400 px-2 py-1 h-full disabled:text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0 transition-colors',
className
)}
disabled={translating}

View File

@@ -13,6 +13,7 @@ const UserAvatarSizeCnMap = {
large: 'w-24 h-24',
big: 'w-16 h-16',
normal: 'w-10 h-10',
medium: 'w-8 h-8',
small: 'w-7 h-7',
xSmall: 'w-5 h-5',
tiny: 'w-4 h-4'
@@ -25,7 +26,7 @@ export default function UserAvatar({
}: {
userId: string
className?: string
size?: 'large' | 'big' | 'normal' | 'small' | 'xSmall' | 'tiny'
size?: 'large' | 'big' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
}) {
const { profile } = useFetchProfile(userId)
const defaultAvatar = useMemo(

View File

@@ -2,13 +2,10 @@ import FollowButton from '@/components/FollowButton'
import Nip05 from '@/components/Nip05'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { useFetchProfile } from '@/hooks'
export default function UserItem({ pubkey }: { pubkey: string }) {
const { profile } = useFetchProfile(pubkey)
return (
<div className="flex gap-2 items-start">
<div className="flex gap-2 items-center h-14">
<UserAvatar userId={pubkey} className="shrink-0" />
<div className="w-full overflow-hidden">
<Username
@@ -17,7 +14,6 @@ export default function UserItem({ pubkey }: { pubkey: string }) {
skeletonClassName="h-4"
/>
<Nip05 pubkey={pubkey} />
<div className="truncate text-muted-foreground text-sm">{profile?.about}</div>
</div>
<FollowButton pubkey={pubkey} />
</div>

View File

@@ -274,6 +274,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'المستخدمون الموثوقون هم الأشخاص الذين تتابعهم والأشخاص الذين يتابعونهم.',
Continue: 'متابعة',
'Successfully updated mute list': 'تم تحديث قائمة الكتم بنجاح'
'Successfully updated mute list': 'تم تحديث قائمة الكتم بنجاح',
'No pubkeys found from {url}': 'لم يتم العثور على مفاتيح عامة من {{url}}'
}
}

View File

@@ -281,6 +281,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'Vertrauenswürdige Benutzer sind Personen, denen du folgst, und Personen, denen sie folgen.',
Continue: 'Weiter',
'Successfully updated mute list': 'Stummschalteliste erfolgreich aktualisiert'
'Successfully updated mute list': 'Stummschalteliste erfolgreich aktualisiert',
'No pubkeys found from {url}': 'Keine Pubkeys von {{url}} gefunden'
}
}

View File

@@ -274,6 +274,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'Trusted users include people you follow and people they follow.',
Continue: 'Continue',
'Successfully updated mute list': 'Successfully updated mute list'
'Successfully updated mute list': 'Successfully updated mute list',
'No pubkeys found from {url}': 'No pubkeys found from {{url}}'
}
}

View File

@@ -279,6 +279,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'Los usuarios confiables incluyen a las personas que sigues y a las personas que ellos siguen.',
Continue: 'Continuar',
'Successfully updated mute list': 'Lista de silenciamiento actualizada con éxito'
'Successfully updated mute list': 'Lista de silenciamiento actualizada con éxito',
'No pubkeys found from {url}': 'No se encontraron pubkeys desde {{url}}'
}
}

View File

@@ -279,6 +279,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'Les utilisateurs de confiance incluent les personnes que vous suivez et les personnes quelles suivent.',
Continue: 'Continuer',
'Successfully updated mute list': 'Liste de sourdine mise à jour avec succès'
'Successfully updated mute list': 'Liste de sourdine mise à jour avec succès',
'No pubkeys found from {url}': 'Aucun pubkey trouvé à partir de {{url}}'
}
}

View File

@@ -278,6 +278,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'Gli utenti fidati includono le persone che segui e le persone che seguono loro.',
Continue: 'Continua',
'Successfully updated mute list': 'Lista di silenziamento aggiornata con successo'
'Successfully updated mute list': 'Lista di silenziamento aggiornata con successo',
'No pubkeys found from {url}': 'Nessun pubkey trovato da {{url}}'
}
}

View File

@@ -276,6 +276,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'信頼できるユーザーには、あなたがフォローしている人とその人がフォローしている人が含まれます。',
Continue: '続行',
'Successfully updated mute list': 'ミュートリストの更新に成功しました'
'Successfully updated mute list': 'ミュートリストの更新に成功しました',
'No pubkeys found from {url}': 'URL {{url}} からのpubkeyは見つかりませんでした'
}
}

View File

@@ -277,6 +277,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'Zaufani użytkownicy to osoby, które obserwujesz i osoby, które oni obserwują.',
Continue: 'Kontynuuj',
'Successfully updated mute list': 'Sukces aktualizacji listy zablokowanych użytkowników'
'Successfully updated mute list': 'Sukces aktualizacji listy zablokowanych użytkowników',
'No pubkeys found from {url}': 'Nie znaleziono kluczy publicznych z {{url}}'
}
}

View File

@@ -277,6 +277,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
Continue: 'Continuar',
'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso'
'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso',
'No pubkeys found from {url}': 'Nenhum pubkey encontrado em {{url}}'
}
}

View File

@@ -278,6 +278,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
Continue: 'Continuar',
'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso'
'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso',
'No pubkeys found from {url}': 'Nenhum pubkey encontrado em {{url}}'
}
}

View File

@@ -279,6 +279,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'Доверенные пользователи включают людей, на которых вы подписаны, и людей, на которых они подписаны.',
Continue: 'Продолжить',
'Successfully updated mute list': 'Успешно обновлен список заглушенных пользователей'
'Successfully updated mute list': 'Успешно обновлен список заглушенных пользователей',
'No pubkeys found from {url}': 'Не найдено pubkeys из {{url}}'
}
}

View File

@@ -273,6 +273,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'ผู้ใช้ที่เชื่อถือได้รวมถึงผู้ที่คุณติดตามและผู้ที่พวกเขาติดตาม',
Continue: 'ดำเนินการต่อ',
'Successfully updated mute list': 'อัปเดตรายการปิดเสียงสำเร็จ'
'Successfully updated mute list': 'อัปเดตรายการปิดเสียงสำเร็จ',
'No pubkeys found from {url}': 'ไม่พบ pubkeys จาก {{url}}'
}
}

View File

@@ -274,6 +274,7 @@ export default {
'Trusted users include people you follow and people they follow.':
'受信任的用户包括您关注的人和他们关注的人。',
Continue: '继续',
'Successfully updated mute list': '成功更新屏蔽列表'
'Successfully updated mute list': '成功更新屏蔽列表',
'No pubkeys found from {url}': '在 {{url}} 中未找到 pubkeys'
}
}

View File

@@ -11,17 +11,20 @@ export const toNote = (eventOrId: Pick<Event, 'id' | 'pubkey'> | string) => {
export const toNoteList = ({
hashtag,
search,
externalContentId
externalContentId,
domain
}: {
hashtag?: string
search?: string
externalContentId?: string
domain?: string
}) => {
const path = '/notes'
const query = new URLSearchParams()
if (hashtag) query.set('t', hashtag.toLowerCase())
if (search) query.set('s', search)
if (externalContentId) query.set('i', externalContentId)
if (domain) query.set('d', domain)
return `${path}?${query.toString()}`
}
export const toProfile = (userId: string) => {
@@ -29,10 +32,11 @@ export const toProfile = (userId: string) => {
const npub = nip19.npubEncode(userId)
return `/users/${npub}`
}
export const toProfileList = ({ search }: { search?: string }) => {
export const toProfileList = ({ search, domain }: { search?: string; domain?: string }) => {
const path = '/users'
const query = new URLSearchParams()
if (search) query.set('s', search)
if (domain) query.set('d', domain)
return `${path}?${query.toString()}`
}
export const toFollowingList = (pubkey: string) => {

View File

@@ -1,4 +1,5 @@
import { LRUCache } from 'lru-cache'
import { isValidPubkey } from './pubkey'
type TVerifyNip05Result = {
isVerified: boolean
@@ -20,7 +21,7 @@ async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05
if (!nip05Name || !nip05Domain || !pubkey) return result
try {
const res = await fetch(`https://${nip05Domain}/.well-known/nostr.json?name=${nip05Name}`)
const res = await fetch(getWellKnownNip05Url(nip05Domain, nip05Name))
const json = await res.json()
if (json.names?.[nip05Name] === pubkey) {
return { ...result, isVerified: true }
@@ -39,3 +40,32 @@ export async function verifyNip05(nip05: string, pubkey: string): Promise<TVerif
const [nip05Name, nip05Domain] = nip05?.split('@') || [undefined, undefined]
return { isVerified: false, nip05Name, nip05Domain }
}
export function getWellKnownNip05Url(domain: string, name?: string): string {
const url = new URL('/.well-known/nostr.json', `https://${domain}`)
if (name) {
url.searchParams.set('name', name)
}
return url.toString()
}
export async function fetchPubkeysFromDomain(domain: string): Promise<string[]> {
try {
const res = await fetch(getWellKnownNip05Url(domain))
const json = await res.json()
const pubkeySet = new Set<string>()
return Object.values(json.names || {}).filter((pubkey) => {
if (typeof pubkey !== 'string' || !isValidPubkey(pubkey)) {
return false
}
if (pubkeySet.has(pubkey)) {
return false
}
pubkeySet.add(pubkey)
return true
}) as string[]
} catch (error) {
console.error('Error fetching pubkeys from domain:', error)
return []
}
}

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
}