feat: nip05 feeds
This commit is contained in:
7
public/.well-known/nostr.json
Normal file
7
public/.well-known/nostr.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"names": {
|
||||
"_": "8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883",
|
||||
"cody": "8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883",
|
||||
"cody2": "24462930821b45f530ec0063eca0a6522e5a577856f982fa944df0ef3caf03ab"
|
||||
}
|
||||
}
|
||||
15
src/components/Favicon/index.tsx
Normal file
15
src/components/Favicon/index.tsx
Normal 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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
45
src/components/ProfileList/index.tsx
Normal file
45
src/components/ProfileList/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 qu’elles 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}}'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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は見つかりませんでした'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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