Browse Source

feat: nip05 feeds

imwald
codytseng 9 months ago
parent
commit
5619905ae0
  1. 7
      public/.well-known/nostr.json
  2. 15
      src/components/Favicon/index.tsx
  3. 16
      src/components/FormattedTimestamp/index.tsx
  4. 40
      src/components/Nip05/index.tsx
  5. 26
      src/components/Note/index.tsx
  6. 45
      src/components/ProfileList/index.tsx
  7. 39
      src/components/ReplyNote/index.tsx
  8. 2
      src/components/TranslateButton/index.tsx
  9. 3
      src/components/UserAvatar/index.tsx
  10. 6
      src/components/UserItem/index.tsx
  11. 3
      src/i18n/locales/ar.ts
  12. 3
      src/i18n/locales/de.ts
  13. 3
      src/i18n/locales/en.ts
  14. 3
      src/i18n/locales/es.ts
  15. 3
      src/i18n/locales/fr.ts
  16. 3
      src/i18n/locales/it.ts
  17. 3
      src/i18n/locales/ja.ts
  18. 3
      src/i18n/locales/pl.ts
  19. 3
      src/i18n/locales/pt-BR.ts
  20. 3
      src/i18n/locales/pt-PT.ts
  21. 3
      src/i18n/locales/ru.ts
  22. 3
      src/i18n/locales/th.ts
  23. 3
      src/i18n/locales/zh.ts
  24. 8
      src/lib/link.ts
  25. 32
      src/lib/nip05.ts
  26. 45
      src/pages/secondary/FollowingListPage/index.tsx
  27. 139
      src/pages/secondary/NoteListPage/index.tsx
  28. 94
      src/pages/secondary/ProfileListPage/index.tsx

7
public/.well-known/nostr.json

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

15
src/components/Favicon/index.tsx

@ -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)}
/>
)
}

16
src/components/FormattedTimestamp/index.tsx

@ -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
}: { }: {

40
src/components/Nip05/index.tsx

@ -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>
)
) )
} }

26
src/components/Note/index.tsx

@ -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 && (

45
src/components/ProfileList/index.tsx

@ -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>
)
}

39
src/components/ReplyNote/index.tsx

@ -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>

2
src/components/TranslateButton/index.tsx

@ -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}

3
src/components/UserAvatar/index.tsx

@ -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(

6
src/components/UserItem/index.tsx

@ -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>

3
src/i18n/locales/ar.ts

@ -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}}'
} }
} }

3
src/i18n/locales/de.ts

@ -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'
} }
} }

3
src/i18n/locales/en.ts

@ -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}}'
} }
} }

3
src/i18n/locales/es.ts

@ -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}}'
} }
} }

3
src/i18n/locales/fr.ts

@ -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 qu’elles suivent.', 'Les utilisateurs de confiance incluent les personnes que vous suivez et les personnes qu’elles 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}}'
} }
} }

3
src/i18n/locales/it.ts

@ -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}}'
} }
} }

3
src/i18n/locales/ja.ts

@ -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は見つかりませんでした'
} }
} }

3
src/i18n/locales/pl.ts

@ -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}}'
} }
} }

3
src/i18n/locales/pt-BR.ts

@ -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}}'
} }
} }

3
src/i18n/locales/pt-PT.ts

@ -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}}'
} }
} }

3
src/i18n/locales/ru.ts

@ -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}}'
} }
} }

3
src/i18n/locales/th.ts

@ -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}}'
} }
} }

3
src/i18n/locales/zh.ts

@ -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'
} }
} }

8
src/lib/link.ts

@ -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) => {

32
src/lib/nip05.ts

@ -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 []
}
}

45
src/pages/secondary/FollowingListPage/index.tsx

@ -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>
) )
}) })

139
src/pages/secondary/NoteListPage/index.tsx

@ -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)
const hashtag = searchParams.get('t')
if (hashtag) {
return {
title: `# ${hashtag}`,
filter: { '#t': [hashtag] },
urls: BIG_RELAY_URLS
} }
} | {
const search = searchParams.get('s') type: 'domain'
if (search) { filter: Filter
return { domain: string
title: `${t('Search')}: ${search}`, urls?: string[]
filter: { search },
urls: SEARCHABLE_RELAY_URLS
} }
} | null
const externalContentId = searchParams.get('i') >(null)
if (externalContentId) {
return { useEffect(() => {
title: externalContentId, const init = async () => {
filter: { '#I': [externalContentId] }, const searchParams = new URLSearchParams(window.location.search)
urls: BIG_RELAY_URLS.concat(relayList?.write || []) 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
} }
} }
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 ( 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>
) )
}) })

94
src/pages/secondary/ProfileListPage/index.tsx

@ -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

Loading…
Cancel
Save