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' import { useTranslation } from 'react-i18next'
export function FormattedTimestamp({ 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, timestamp,
short = false short = false
}: { }: {

View File

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

View File

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

View File

@@ -122,7 +122,7 @@ export default function TranslateButton({
return ( return (
<button <button
className={cn( 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 className
)} )}
disabled={translating} disabled={translating}

View File

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

View File

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

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': 'تم تحديث قائمة الكتم بنجاح',
'No pubkeys found from {url}': 'لم يتم العثور على مفاتيح عامة من {{url}}'
} }
} }

View File

@@ -281,6 +281,7 @@ export default {
'Trusted users include people you follow and people they follow.': 'Trusted users include people you follow and people they follow.':
'Vertrauenswürdige Benutzer sind Personen, denen du folgst, und Personen, denen sie folgen.', 'Vertrauenswürdige Benutzer sind Personen, denen du folgst, und Personen, denen sie folgen.',
Continue: 'Weiter', 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.':
'Trusted users include people you follow and people they follow.', 'Trusted users include people you follow and people they follow.',
Continue: 'Continue', 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.': '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.', 'Los usuarios confiables incluyen a las personas que sigues y a las personas que ellos siguen.',
Continue: 'Continuar', 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.': '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.', 'Les utilisateurs de confiance incluent les personnes que vous suivez et les personnes quelles suivent.',
Continue: 'Continuer', 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.': 'Trusted users include people you follow and people they follow.':
'Gli utenti fidati includono le persone che segui e le persone che seguono loro.', 'Gli utenti fidati includono le persone che segui e le persone che seguono loro.',
Continue: 'Continua', 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.': 'Trusted users include people you follow and people they follow.':
'信頼できるユーザーには、あなたがフォローしている人とその人がフォローしている人が含まれます。', '信頼できるユーザーには、あなたがフォローしている人とその人がフォローしている人が含まれます。',
Continue: '続行', 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.': 'Trusted users include people you follow and people they follow.':
'Zaufani użytkownicy to osoby, które obserwujesz i osoby, które oni obserwują.', 'Zaufani użytkownicy to osoby, które obserwujesz i osoby, które oni obserwują.',
Continue: 'Kontynuuj', 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.': 'Trusted users include people you follow and people they follow.':
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.', 'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
Continue: 'Continuar', 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.': 'Trusted users include people you follow and people they follow.':
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.', 'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
Continue: 'Continuar', 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.': 'Trusted users include people you follow and people they follow.':
'Доверенные пользователи включают людей, на которых вы подписаны, и людей, на которых они подписаны.', 'Доверенные пользователи включают людей, на которых вы подписаны, и людей, на которых они подписаны.',
Continue: 'Продолжить', 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.': 'Trusted users include people you follow and people they follow.':
'ผู้ใช้ที่เชื่อถือได้รวมถึงผู้ที่คุณติดตามและผู้ที่พวกเขาติดตาม', 'ผู้ใช้ที่เชื่อถือได้รวมถึงผู้ที่คุณติดตามและผู้ที่พวกเขาติดตาม',
Continue: 'ดำเนินการต่อ', 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.': 'Trusted users include people you follow and people they follow.':
'受信任的用户包括您关注的人和他们关注的人。', '受信任的用户包括您关注的人和他们关注的人。',
Continue: '继续', 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 = ({ export const toNoteList = ({
hashtag, hashtag,
search, search,
externalContentId externalContentId,
domain
}: { }: {
hashtag?: string hashtag?: string
search?: string search?: string
externalContentId?: string externalContentId?: string
domain?: string
}) => { }) => {
const path = '/notes' const path = '/notes'
const query = new URLSearchParams() const query = new URLSearchParams()
if (hashtag) query.set('t', hashtag.toLowerCase()) if (hashtag) query.set('t', hashtag.toLowerCase())
if (search) query.set('s', search) if (search) query.set('s', search)
if (externalContentId) query.set('i', externalContentId) if (externalContentId) query.set('i', externalContentId)
if (domain) query.set('d', domain)
return `${path}?${query.toString()}` return `${path}?${query.toString()}`
} }
export const toProfile = (userId: string) => { export const toProfile = (userId: string) => {
@@ -29,10 +32,11 @@ export const toProfile = (userId: string) => {
const npub = nip19.npubEncode(userId) const npub = nip19.npubEncode(userId)
return `/users/${npub}` return `/users/${npub}`
} }
export const toProfileList = ({ search }: { search?: string }) => { export const toProfileList = ({ search, domain }: { search?: string; domain?: string }) => {
const path = '/users' const path = '/users'
const query = new URLSearchParams() const query = new URLSearchParams()
if (search) query.set('s', search) if (search) query.set('s', search)
if (domain) query.set('d', domain)
return `${path}?${query.toString()}` return `${path}?${query.toString()}`
} }
export const toFollowingList = (pubkey: string) => { export const toFollowingList = (pubkey: string) => {

View File

@@ -1,4 +1,5 @@
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import { isValidPubkey } from './pubkey'
type TVerifyNip05Result = { type TVerifyNip05Result = {
isVerified: boolean isVerified: boolean
@@ -20,7 +21,7 @@ async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05
if (!nip05Name || !nip05Domain || !pubkey) return result if (!nip05Name || !nip05Domain || !pubkey) return result
try { 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() const json = await res.json()
if (json.names?.[nip05Name] === pubkey) { if (json.names?.[nip05Name] === pubkey) {
return { ...result, isVerified: true } 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] const [nip05Name, nip05Domain] = nip05?.split('@') || [undefined, undefined]
return { isVerified: false, nip05Name, nip05Domain } 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 { useFetchFollowings, useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef, useEffect, useRef, useState } from 'react' import { forwardRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const FollowingListPage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => { const FollowingListPage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { profile } = useFetchProfile(id) const { profile } = useFetchProfile(id)
const { followings } = useFetchFollowings(profile?.pubkey) 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 ( return (
<SecondaryPageLayout <SecondaryPageLayout
@@ -54,12 +20,7 @@ const FollowingListPage = forwardRef(({ id, index }: { id?: string; index?: numb
} }
displayScrollToTopButton displayScrollToTopButton
> >
<div className="space-y-2 px-4"> <ProfileList pubkeys={followings} />
{visibleFollowings.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{followings.length > visibleFollowings.length && <div ref={bottomRef} />}
</div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
}) })

View File

@@ -1,54 +1,125 @@
import { Favicon } from '@/components/Favicon'
import NoteList from '@/components/NoteList' import NoteList from '@/components/NoteList'
import { Button } from '@/components/ui/button'
import { BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' 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 { useNostr } from '@/providers/NostrProvider'
import { UserRound } from 'lucide-react'
import { Filter } from 'nostr-tools' import { Filter } from 'nostr-tools'
import { forwardRef, useMemo } from 'react' import React, { forwardRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => { const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage()
const { relayList } = useNostr() const { relayList } = useNostr()
const { const [title, setTitle] = useState<React.ReactNode>(null)
title = '', const [controls, setControls] = useState<React.ReactNode>(null)
filter, const [data, setData] = useState<
urls | {
} = useMemo<{ type: 'hashtag' | 'search' | 'externalContent'
title?: string filter: Filter
filter?: Filter urls: string[]
urls: string[] }
}>(() => { | {
const searchParams = new URLSearchParams(window.location.search) type: 'domain'
const hashtag = searchParams.get('t') filter: Filter
if (hashtag) { domain: string
return { urls?: string[]
title: `# ${hashtag}`, }
filter: { '#t': [hashtag] }, | null
urls: BIG_RELAY_URLS >(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') init()
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 }
}, []) }, [])
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 ( return (
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton> <SecondaryPageLayout
<NoteList key={title} filter={filter} relayUrls={urls} /> ref={ref}
index={index}
title={title}
controls={controls}
displayScrollToTopButton
>
{content}
</SecondaryPageLayout> </SecondaryPageLayout>
) )
}) })

View File

@@ -1,11 +1,13 @@
import { Favicon } from '@/components/Favicon'
import ProfileList from '@/components/ProfileList'
import UserItem from '@/components/UserItem' import UserItem from '@/components/UserItem'
import { SEARCHABLE_RELAY_URLS } from '@/constants' import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { useFetchRelayInfos } from '@/hooks' import { useFetchRelayInfos } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { fetchPubkeysFromDomain } from '@/lib/nip05'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Filter } from 'nostr-tools'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -13,27 +15,75 @@ const LIMIT = 50
const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => { const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation() 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 { relayUrls } = useFeed()
const { searchableRelayUrls } = useFetchRelayInfos(relayUrls) const { searchableRelayUrls } = useFetchRelayInfos(relayUrls)
const [until, setUntil] = useState<number>(() => dayjs().unix()) const [until, setUntil] = useState<number>(() => dayjs().unix())
const [hasMore, setHasMore] = useState<boolean>(true) const [hasMore, setHasMore] = useState<boolean>(true)
const [pubkeySet, setPubkeySet] = useState(new Set<string>()) const [pubkeySet, setPubkeySet] = useState(new Set<string>())
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const filter = useMemo(() => { const filter = { until, search }
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 urls = useMemo(() => { const urls = useMemo(() => {
return filter.search ? searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4) : relayUrls return filter.search ? searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4) : relayUrls
}, [relayUrls, searchableRelayUrls, filter]) }, [relayUrls, searchableRelayUrls, filter])
const title = useMemo(() => {
return filter.search ? `${t('Search')}: ${filter.search}` : t('All users')
}, [filter])
useEffect(() => { useEffect(() => {
if (!hasMore) return if (!hasMore) return
@@ -80,15 +130,11 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
} }
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton> <div className="px-4">
<div className="space-y-2 px-4"> {Array.from(pubkeySet).map((pubkey, index) => (
{Array.from(pubkeySet).map((pubkey, index) => ( <UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} /> ))}
))} {hasMore && <div ref={bottomRef} />}
{hasMore && <div ref={bottomRef} />} </div>
</div>
</SecondaryPageLayout>
) )
}) }
ProfileListPage.displayName = 'ProfileListPage'
export default ProfileListPage