Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
e800f7b3c3
  1. 18
      src/PageManager.tsx
  2. 4
      src/components/Nip05/index.tsx
  3. 14
      src/components/Nip05DomainPanel/Nip05DomainEmptyState.tsx
  4. 83
      src/components/Nip05DomainPanel/ProfileListByNip05Domain.tsx
  5. 25
      src/components/Nip05DomainPanel/WellKnownNip05UrlLink.tsx
  6. 4
      src/components/Nip05List/index.tsx
  7. 4
      src/components/NormalFeed/index.tsx
  8. 50
      src/components/NoteDrawer/index.tsx
  9. 74
      src/components/NoteList/index.tsx
  10. 1
      src/components/NoteStats/ZapButton.tsx
  11. 1
      src/components/ZapDialog/index.tsx
  12. 21
      src/hooks/useNip57QuickZap.ts
  13. 1
      src/i18n/locales/de.ts
  14. 1
      src/i18n/locales/en.ts
  15. 33
      src/layouts/PrimaryPageLayout/index.tsx
  16. 17
      src/lib/mobile-primary-feed-scroll.ts
  17. 71
      src/lib/nip05-well-known.test.ts
  18. 251
      src/lib/nip05.ts
  19. 11
      src/pages/secondary/NoteListPage/index.tsx
  20. 19
      src/pages/secondary/ProfileListPage/index.tsx

18
src/PageManager.tsx

@ -7,6 +7,7 @@ import { RefreshButton } from '@/components/RefreshButton' @@ -7,6 +7,7 @@ import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import { captureMobilePrimaryFeedScrollFromWindow, peekMobilePrimaryFeedScroll } from '@/lib/mobile-primary-feed-scroll'
import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back'
import { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard'
import { ChevronLeft } from 'lucide-react'
@ -2033,6 +2034,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2033,6 +2034,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
noteStatsService.setBackgroundStatsPaused(true)
client.interruptBackgroundQueries()
if (isSmallScreen && currentPrimaryPage) {
captureMobilePrimaryFeedScrollFromWindow(currentPrimaryPage)
}
// Small screens render either the primary overlay OR the secondary stack — not both.
// Clear overlays (e.g. full-screen note) so pushes from Seen-on, settings deep links, etc. show the target page.
if (isSmallScreen && primaryNoteView) {
@ -2109,6 +2114,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2109,6 +2114,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
)
currentTabStateRef.current.set(page, savedFeedState.tab)
}
if (isSmallScreen) {
const top = peekMobilePrimaryFeedScroll(page)
requestAnimationFrame(() => {
window.scrollTo({ top, behavior: 'instant' })
})
}
}
const hardCloseSecondaryPanel = () => {
@ -2223,7 +2234,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2223,7 +2234,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
popSecondaryPageRef.current = popSecondaryPage
const mobileSecondaryPanelOpen =
isSmallScreen && secondaryStack.length > 0 && !primaryNoteView
isSmallScreen &&
secondaryStack.length > 0 &&
!primaryNoteView &&
!(drawerOpen && drawerNoteId)
useMobileSwipeBackOnElement(mobileSecondaryPanelOpen ? mobileSecondarySwipeRoot : null, () =>
popSecondaryPageRef.current()
, {
@ -2338,7 +2352,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2338,7 +2352,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div>
) : (
<>
{secondaryStack.length > 0 ? (
{secondaryStack.length > 0 && !(drawerOpen && drawerNoteId) ? (
<div
ref={setMobileSecondarySwipeRoot}
className="flex min-h-0 min-w-0 flex-1 flex-col touch-pan-y"

4
src/components/Nip05/index.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks'
import { useFetchNip05 } from '@/hooks/useFetchNip05'
import { toNoteList } from '@/lib/link'
import { toProfileList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
import { BadgeAlert, BadgeCheck } from 'lucide-react'
import { Favicon } from '../Favicon'
@ -48,7 +48,7 @@ export default function Nip05({ @@ -48,7 +48,7 @@ export default function Nip05({
<BadgeAlert className="text-muted-foreground" />
)}
<SecondaryPageLink
to={toNoteList({ domain: nip05Domain })}
to={toProfileList({ domain: nip05Domain })}
className={`truncate text-sm hover:text-foreground hover:underline underline-offset-2 transition-colors ${nip05IsVerified ? 'text-primary' : 'text-muted-foreground'}`}
>
{nip05Domain}

14
src/components/Nip05DomainPanel/Nip05DomainEmptyState.tsx

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
import { useTranslation } from 'react-i18next'
import WellKnownNip05UrlLink from './WellKnownNip05UrlLink'
export default function Nip05DomainEmptyState({ domain }: { domain: string }) {
const { t } = useTranslation()
return (
<div className="w-full px-4 py-10 text-center">
<p className="text-muted-foreground">{t('No pubkeys found on NIP-05 domain')}</p>
<p className="mt-2">
<WellKnownNip05UrlLink domain={domain} />
</p>
</div>
)
}

83
src/components/Nip05DomainPanel/ProfileListByNip05Domain.tsx

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
import UserItem from '@/components/UserItem'
import { Skeleton } from '@/components/ui/skeleton'
import { fetchNip05NamePubkeysFromDomain } from '@/lib/nip05'
import { useEffect, useMemo, useRef, useState } from 'react'
import Nip05DomainEmptyState from './Nip05DomainEmptyState'
export type TNip05NamePubkey = { name: string; pubkey: string }
export default function ProfileListByNip05Domain({ domain }: { domain: string }) {
const [entries, setEntries] = useState<TNip05NamePubkey[] | null>(null)
const [visibleCount, setVisibleCount] = useState(10)
const bottomRef = useRef<HTMLDivElement>(null)
const entriesKey = useMemo(
() => (entries ? entries.map((e) => `${e.name}:${e.pubkey}`).join('\u0001') : ''),
[entries]
)
useEffect(() => {
let cancelled = false
setEntries(null)
setVisibleCount(10)
void fetchNip05NamePubkeysFromDomain(domain).then((rows) => {
if (!cancelled) setEntries(rows)
})
return () => {
cancelled = true
}
}, [domain])
useEffect(() => {
if (!entries?.length) return
setVisibleCount(10)
}, [entriesKey, entries])
useEffect(() => {
if (!entries?.length) return
const options = { root: null, rootMargin: '10px', threshold: 1 }
const observer = new IntersectionObserver((obs) => {
if (obs[0]?.isIntersecting && visibleCount < entries.length) {
setVisibleCount((n) => Math.min(n + 10, entries.length))
}
}, options)
const node = bottomRef.current
if (node) observer.observe(node)
return () => {
if (node) observer.unobserve(node)
}
}, [visibleCount, entriesKey, entries])
if (entries === null) {
return (
<div className="space-y-2 px-4 pt-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)
}
if (entries.length === 0) {
return <Nip05DomainEmptyState domain={domain} />
}
const visible = entries.slice(0, visibleCount)
return (
<div className="px-4 pt-2">
{visible.map(({ name, pubkey }) => (
<div
key={pubkey}
className="flex min-w-0 items-center gap-2 border-b border-border/40 py-1 last:border-0"
>
{name && name !== '_' ? (
<span className="shrink-0 text-sm text-muted-foreground">{name}</span>
) : null}
<div className="min-w-0 flex-1">
<UserItem pubkey={pubkey} hideNip05 />
</div>
</div>
))}
{visibleCount < entries.length ? <div ref={bottomRef} className="h-4" /> : null}
</div>
)
}

25
src/components/Nip05DomainPanel/WellKnownNip05UrlLink.tsx

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
import { getWellKnownNip05Url } from '@/lib/nip05'
import { cn } from '@/lib/utils'
export default function WellKnownNip05UrlLink({
domain,
className
}: {
domain: string
className?: string
}) {
const url = getWellKnownNip05Url(domain)
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'text-primary hover:underline underline-offset-2 break-all',
className
)}
>
{url}
</a>
)
}

4
src/components/Nip05List/index.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { Skeleton } from '@/components/ui/skeleton'
import { splitNip05Identifier, verifyNip05 } from '@/lib/nip05'
import { toNoteList } from '@/lib/link'
import { toProfileList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
import { BadgeAlert, BadgeCheck } from 'lucide-react'
import { Favicon } from '../Favicon'
@ -114,7 +114,7 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[]; @@ -114,7 +114,7 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[];
<BadgeAlert className="text-muted-foreground" />
)}
<SecondaryPageLink
to={toNoteList({ domain: nip05Domain })}
to={toProfileList({ domain: nip05Domain })}
className={`truncate text-sm hover:text-foreground hover:underline underline-offset-2 transition-colors ${isVerified ? 'text-primary' : 'text-muted-foreground'}`}
>
{nip05Domain}

4
src/components/NormalFeed/index.tsx

@ -348,7 +348,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -348,7 +348,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
<div className="min-w-0 flex-1">{tabsElement}</div>
<div
ref={onFeedFilterTabRowSlotRef}
className="flex shrink-0 flex-col items-end justify-center self-center"
className="flex shrink-0 flex-col items-end self-start"
/>
</div>
)
@ -375,7 +375,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -375,7 +375,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
<div className="min-w-0 flex-1">{tabsElement}</div>
<div
ref={onFeedFilterTabRowSlotRef}
className="flex shrink-0 flex-col items-end justify-center self-center"
className="flex shrink-0 flex-col items-end self-start"
/>
</div>
</div>

50
src/components/NoteDrawer/index.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back'
import { useState, useEffect, useRef, useCallback } from 'react'
import { MOBILE_SWIPE_BACK_EDGE_PX, useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back'
import { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import NotePage from '@/pages/secondary/NotePage'
@ -16,23 +16,43 @@ interface NoteDrawerProps { @@ -16,23 +16,43 @@ interface NoteDrawerProps {
export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: NoteDrawerProps) {
const { currentIndex, pop } = useSecondaryPage()
const [displayNoteId, setDisplayNoteId] = useState<string | null>(noteId)
const [swipeRoot, setSwipeRoot] = useState<HTMLElement | null>(null)
const [swipeEdge, setSwipeEdge] = useState<HTMLElement | null>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const closingFromSwipeRef = useRef(false)
useMobileSwipeBackOnElement(open ? swipeRoot : null, pop, { enabled: open })
const handleSwipeBack = useCallback(() => {
if (!open || closingFromSwipeRef.current) return
closingFromSwipeRef.current = true
pop()
}, [open, pop])
useEffect(() => {
if (open) closingFromSwipeRef.current = false
}, [open, noteId])
useMobileSwipeBackOnElement(open ? swipeEdge : null, handleSwipeBack, {
enabled: open,
edgePx: MOBILE_SWIPE_BACK_EDGE_PX
})
useEffect(() => {
if (!open) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = prev
}
}, [open])
useEffect(() => {
// Clear any pending timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
if (noteId) {
// New noteId - show immediately
setDisplayNoteId(noteId)
} else if (!open && displayNoteId) {
// Closing - keep content visible during animation (350ms)
timeoutRef.current = setTimeout(() => {
setDisplayNoteId(null)
timeoutRef.current = null
@ -49,19 +69,21 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: @@ -49,19 +69,21 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }:
if (!displayNoteId) return null
return (
<Sheet
open={open}
onOpenChange={onOpenChange}
registerWithModalManager={false}
>
<Sheet open={open} onOpenChange={onOpenChange} registerWithModalManager={false}>
<SheetContent
side="right"
className="w-full sm:max-w-[1042px] overflow-y-auto p-0"
className="relative w-full overscroll-contain sm:max-w-[1042px] overflow-y-auto p-0"
hideClose
onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
>
<div ref={setSwipeRoot} className="min-h-full touch-pan-y">
<div
ref={setSwipeEdge}
className="absolute inset-y-0 left-0 z-20 touch-none"
style={{ width: MOBILE_SWIPE_BACK_EDGE_PX }}
aria-hidden
/>
<div className="min-h-full touch-pan-y">
<NotePage
id={displayNoteId}
index={currentIndex}

74
src/components/NoteList/index.tsx

@ -4407,31 +4407,32 @@ const NoteList = forwardRef( @@ -4407,31 +4407,32 @@ const NoteList = forwardRef(
const useFeedFilterTabRowPortal =
showFeedClientFilter && typeof feedClientFilterTabRowHost !== 'undefined'
const feedClientFilterPanelSurfaceClass =
const feedClientFilterPanelPortalMode =
useFeedFilterTabRowPortal && feedClientFilterTabRowHost
? 'mt-1 w-[min(100vw-1rem,28rem)] max-w-[calc(100vw-1rem)] space-y-3 rounded-lg border border-border bg-background p-3 shadow-lg'
: 'space-y-3 border-t border-border/60 px-2 py-3'
const feedClientFilterPanelSurfaceClass = feedClientFilterPanelPortalMode
? 'absolute top-full right-0 z-50 mt-1 w-[min(100vw-1rem,28rem)] max-w-[calc(100vw-1rem)] space-y-3 rounded-lg border border-border bg-background p-3 shadow-lg'
: 'space-y-3 border-t border-border/60 px-2 py-3'
const feedClientFilterSectionClass = 'space-y-2 rounded-md border border-border/60 bg-muted/25 p-2.5'
const feedClientFilterChrome = (
<>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-lg leading-none"
aria-expanded={feedClientFilterOpen}
aria-controls="feed-client-filter-panel"
aria-label={t('Feed filter')}
title={t('Feed filter')}
onClick={onToggleFeedClientFilterPanel}
>
<span aria-hidden>🔍</span>
</Button>
</div>
{feedClientFilterOpen ? (
<div id="feed-client-filter-panel" className={feedClientFilterPanelSurfaceClass}>
const feedClientFilterToggleButton = (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-lg leading-none"
aria-expanded={feedClientFilterOpen}
aria-controls="feed-client-filter-panel"
aria-label={t('Feed filter')}
title={t('Feed filter')}
onClick={onToggleFeedClientFilterPanel}
>
<span aria-hidden>🔍</span>
</Button>
)
const feedClientFilterPanel = feedClientFilterOpen ? (
<div id="feed-client-filter-panel" className={feedClientFilterPanelSurfaceClass}>
<div className={feedClientFilterSectionClass}>
<Label htmlFor="feed-client-search" className="text-sm font-medium">
{t('Search loaded posts')}
@ -4560,19 +4561,25 @@ const NoteList = forwardRef( @@ -4560,19 +4561,25 @@ const NoteList = forwardRef(
<p className="px-0.5 text-xs leading-relaxed text-muted-foreground">
{t('Feed filter client-side hint')}
</p>
<div className="flex flex-wrap items-center gap-2 pt-0.5">
<div className="flex flex-col gap-2 pt-0.5 sm:flex-row sm:flex-wrap sm:items-center">
<Button
type="button"
variant="secondary"
size="sm"
className="h-8"
className="h-auto min-h-8 max-w-full whitespace-normal px-3 py-1.5 text-left sm:text-center"
disabled={feedFullSearchLoading}
onClick={() => void onPerformFeedFullSearch()}
>
{feedFullSearchLoading ? t('Feed full search running') : t('Feed full search')}
</Button>
{feedFullSearchEvents !== null ? (
<Button type="button" variant="outline" size="sm" className="h-8" onClick={onClearFeedFullSearch}>
<Button
type="button"
variant="outline"
size="sm"
className="h-auto min-h-8 max-w-full whitespace-normal px-3 py-1.5 text-left sm:text-center"
onClick={onClearFeedFullSearch}
>
{t('Feed full search clear')}
</Button>
) : null}
@ -4581,7 +4588,17 @@ const NoteList = forwardRef( @@ -4581,7 +4588,17 @@ const NoteList = forwardRef(
<p className="text-xs text-muted-foreground">{t('Feed full search active hint')}</p>
) : null}
</div>
) : null}
) : null
const feedClientFilterChrome = feedClientFilterPanelPortalMode ? (
<div className="relative flex items-center gap-1">
{feedClientFilterToggleButton}
{feedClientFilterPanel}
</div>
) : (
<>
<div className="flex items-center gap-1">{feedClientFilterToggleButton}</div>
{feedClientFilterPanel}
</>
)
@ -4593,10 +4610,7 @@ const NoteList = forwardRef( @@ -4593,10 +4610,7 @@ const NoteList = forwardRef(
const feedClientFilterBar =
useFeedFilterTabRowPortal && feedClientFilterTabRowHost
? createPortal(
<div className="flex flex-col items-end gap-0">{feedClientFilterChrome}</div>,
feedClientFilterTabRowHost
)
? createPortal(feedClientFilterChrome, feedClientFilterTabRowHost)
: useFeedFilterTabRowPortal && !feedClientFilterTabRowHost
? null
: feedClientFilterBarEmbedded

1
src/components/NoteStats/ZapButton.tsx

@ -228,7 +228,6 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -228,7 +228,6 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
recipientPubkey: event.pubkey,
referencedEvent: event,
recipientPayment: recipientPaymentForZap,
onPostPaymentRequest: handlePostPaymentRequest,
onZapDialogClose: () => setOpenPaymentDialog(false)
})

1
src/components/ZapDialog/index.tsx

@ -99,7 +99,6 @@ export default function ZapDialog({ @@ -99,7 +99,6 @@ export default function ZapDialog({
recipientPubkey: pubkey,
referencedEvent: event,
recipientPayment,
onPostPaymentRequest: openPostPaymentPrompt,
onZapDialogClose: () => setOpen(false)
})

21
src/hooks/useNip57QuickZap.ts

@ -1,6 +1,4 @@ @@ -1,6 +1,4 @@
import { buildOrderedZapLightningAddresses } from '@/lib/merge-payment-methods'
import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
import { buildPaytoUri } from '@/lib/payto'
import { formatNpub, pubkeyToNpub } from '@/lib/pubkey'
import { useNostr } from '@/providers/NostrProvider'
import { useZap } from '@/providers/ZapProvider'
@ -17,7 +15,6 @@ export function useNip57QuickZap(opts: { @@ -17,7 +15,6 @@ export function useNip57QuickZap(opts: {
recipientPubkey: string
referencedEvent?: NostrEvent
recipientPayment: RecipientPaymentData
onPostPaymentRequest?: (context: PostPaymentContext) => void
onZapDialogClose?: () => void
}) {
const { t } = useTranslation()
@ -58,29 +55,13 @@ export function useNip57QuickZap(opts: { @@ -58,29 +55,13 @@ export function useNip57QuickZap(opts: {
if (!pubkey) return
try {
setZapping(true)
const paymentDetails = {
amountMsat: defaultZapSats * 1000,
paytoUri: buildPaytoUri('lightning', lightningAddressOptions[0] ?? ''),
messageDraft: defaultZapComment.trim() || undefined
}
const zapResult = await lightning.zap(
pubkey,
opts.referencedEvent ?? opts.recipientPubkey,
defaultZapSats,
defaultZapComment,
opts.onZapDialogClose,
(result) => {
if (!result) return
opts.onPostPaymentRequest?.(
mergePostPaymentContext(
{
recipientPubkey: opts.recipientPubkey,
referencedEvent: opts.referencedEvent
},
paymentDetails
)
)
},
undefined,
{
address: lightningAddressOptions[0],
candidates: lightningAddressOptions

1
src/i18n/locales/de.ts

@ -989,6 +989,7 @@ export default { @@ -989,6 +989,7 @@ export default {
Continue: 'Weiter',
'Successfully updated mute list': 'Stummschalteliste erfolgreich aktualisiert',
'No pubkeys found from {url}': 'Keine Pubkeys von {{url}} gefunden',
'No pubkeys found on NIP-05 domain': 'Keine Pubkeys in dieser NIP-05-Domain gefunden',
'Translating...': 'Übersetze...',
Translate: 'Übersetzen',
'Show original': 'Original anzeigen',

1
src/i18n/locales/en.ts

@ -979,6 +979,7 @@ export default { @@ -979,6 +979,7 @@ export default {
Continue: 'Continue',
'Successfully updated mute list': 'Successfully updated mute list',
'No pubkeys found from {url}': 'No pubkeys found from {{url}}',
'No pubkeys found on NIP-05 domain': 'No pubkeys found on this NIP-05 domain',
'Translating...': 'Translating...',
Translate: 'Translate',
'Show original': 'Show original',

33
src/layouts/PrimaryPageLayout/index.tsx

@ -11,6 +11,10 @@ import { @@ -11,6 +11,10 @@ import {
isRadixDialogOpen,
shouldIgnoreKeyboardShortcutEvent
} from '@/lib/keyboard-shortcuts'
import {
peekMobilePrimaryFeedScroll,
saveMobilePrimaryFeedScroll
} from '@/lib/mobile-primary-feed-scroll'
import { cn } from '@/lib/utils'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
@ -42,7 +46,6 @@ const PrimaryPageLayout = forwardRef( @@ -42,7 +46,6 @@ const PrimaryPageLayout = forwardRef(
) => {
const scrollAreaRef = useRef<HTMLDivElement>(null)
const smallScreenScrollAreaRef = useRef<HTMLDivElement>(null)
const smallScreenLastScrollTopRef = useRef(0)
const { isSmallScreen } = useScreenSize()
const { current, display, frozen } = usePrimaryPage()
const savedScrollTopRef = useRef(0)
@ -64,27 +67,25 @@ const PrimaryPageLayout = forwardRef( @@ -64,27 +67,25 @@ const PrimaryPageLayout = forwardRef(
)
useEffect(() => {
if (!isSmallScreen) return
const isVisible = () => {
return smallScreenScrollAreaRef.current?.checkVisibility
? smallScreenScrollAreaRef.current?.checkVisibility()
: false
}
if (!isSmallScreen || current !== pageName) return
if (isVisible()) {
window.scrollTo({ top: smallScreenLastScrollTopRef.current, behavior: 'instant' })
}
const handleScroll = () => {
if (isVisible()) {
smallScreenLastScrollTopRef.current = window.scrollY
}
saveMobilePrimaryFeedScroll(pageName, window.scrollY)
}
window.addEventListener('scroll', handleScroll)
window.addEventListener('scroll', handleScroll, { passive: true })
return () => {
handleScroll()
window.removeEventListener('scroll', handleScroll)
}
}, [current, isSmallScreen, display])
}, [current, isSmallScreen, pageName])
useEffect(() => {
if (!isSmallScreen || current !== pageName || !display) return
const top = peekMobilePrimaryFeedScroll(pageName)
requestAnimationFrame(() => {
window.scrollTo({ top, behavior: 'instant' })
})
}, [current, display, isSmallScreen, pageName])
useEffect(() => {
if (isSmallScreen) return

17
src/lib/mobile-primary-feed-scroll.ts

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
import type { TPrimaryPageName } from '@/PageManager'
/** Persist primary feed window scroll across mobile secondary unmount (PageManager hides the feed while a panel is open). */
const scrollByPage = new Map<TPrimaryPageName, number>()
export function saveMobilePrimaryFeedScroll(page: TPrimaryPageName, top: number): void {
if (!Number.isFinite(top) || top < 0) return
scrollByPage.set(page, top)
}
export function peekMobilePrimaryFeedScroll(page: TPrimaryPageName): number {
return scrollByPage.get(page) ?? 0
}
export function captureMobilePrimaryFeedScrollFromWindow(page: TPrimaryPageName): void {
saveMobilePrimaryFeedScroll(page, window.scrollY)
}

71
src/lib/nip05-well-known.test.ts

@ -0,0 +1,71 @@ @@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { nip19 } from 'nostr-tools'
import { parseNip05NamePubkeyEntry, parseNip05NamePubkeysFromWellKnownJson } from '@/lib/nip05'
const THEFOREST_WELL_KNOWN = {
names: {
'137': '6da819f91d69cbe591c08b31f555c6d0ab9905197eb515856e339049c018c1af',
'430': '6da819f91d69cbe591c08b31f555c6d0ab9905197eb515856e339049c018c1af',
cloudfodder: '7cc328a08ddb2afdf9f9be77beff4c83489ff979721827d628a542f32a247c0e',
laeserin: 'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319',
testuser: 'd04bc6808885b8db9c344675c89b442e5f1c30430548bfb263731e1b662d4846',
testerin: '573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc',
metoo: '59c67d18b2e470fc5a6dd27c8f21ed538d9047808304ebd3305bc104dfc83627',
crackerjack: '58d953d05e751803dbb7b8b58f130b5553c13286e04951eac901c50e08ee7313',
nostrbots: 'dcc95cc9b0b85802e634f407ed91990471bd69d4af07e719eea8ddfc93f20153',
poe: 'e034d654802d7cfaa2d41a952801054114e09ad6a352b28288e23075ca919814',
YODL: 'd28413712171c33e117d4bd0930ac05b2c51b30eb3021ef8d4f1233f02c90a2b',
superuserdo: '5b0867ea4a23b3b04fe17d0ed52d4529661514b4d84d4a1d86f98eb7c175aab1',
daniel: 'ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74',
orange: 'de599d3d84a30f8dcb1dd86658655b1ee0318880dc9e53cdc6c367c0b9498700',
ThatWhichisNotSeen: 'cf8f07ebffbdce4976ea8ab830cfd6036ffb6203e67ba8eb7a9a448a742a6eaa'
},
relays: {
'137': [],
cloudfodder: ['wss://nostr21.com'],
laeserin: [],
metoo: ['wss://nostr21.com']
}
} as const
describe('parseNip05NamePubkeysFromWellKnownJson', () => {
it('parses theforest.nostr1.com well-known names', () => {
const rows = parseNip05NamePubkeysFromWellKnownJson(THEFOREST_WELL_KNOWN)
expect(rows).toHaveLength(15)
expect(rows.find((r) => r.name === 'laeserin')?.pubkey).toBe(
'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319'
)
expect(rows.find((r) => r.name === '137')?.pubkey).toBe(
'6da819f91d69cbe591c08b31f555c6d0ab9905197eb515856e339049c018c1af'
)
expect(rows.find((r) => r.name === 'YODL')?.pubkey).toBe(
'd28413712171c33e117d4bd0930ac05b2c51b30eb3021ef8d4f1233f02c90a2b'
)
})
it('rejects proxy error stubs without names', () => {
expect(parseNip05NamePubkeysFromWellKnownJson({ ok: false, error: 'og_proxy_unreachable' })).toEqual(
[]
)
})
it('parses npub-keyed names with username labels', () => {
const laeserinHex = 'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319'
const npub = nip19.npubEncode(laeserinHex)
const rows = parseNip05NamePubkeysFromWellKnownJson({
names: {
[npub]: 'laeserin'
}
})
expect(rows).toEqual([{ name: 'laeserin', pubkey: laeserinHex }])
})
it('matches npub keys when resolving entries', () => {
const laeserinHex = 'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319'
const npub = nip19.npubEncode(laeserinHex)
expect(parseNip05NamePubkeyEntry(npub, 'laeserin')).toEqual({
name: 'laeserin',
pubkey: laeserinHex
})
})
})

251
src/lib/nip05.ts

@ -18,9 +18,12 @@ type TVerifyNip05Result = { @@ -18,9 +18,12 @@ type TVerifyNip05Result = {
}
/** Bumps when verification rules change so LRU does not serve stale false negatives. */
const VERIFY_CACHE_SCHEMA = 4
const VERIFY_CACHE_SCHEMA = 5
type WellKnownCacheEntry = { json: Record<string, unknown> | null }
/** Bumps when well-known fetch/parse rules change so stale empty cache entries are not reused. */
const WELL_KNOWN_CACHE_SCHEMA = 3
type WellKnownCacheEntry = { json: Record<string, unknown> | null; schema: number }
/** Per-domain `nostr.json` (or negative `null`) so feeds do not re-fetch every NIP-05 on the same host. */
const wellKnownJsonByDomain = new LRUCache<string, WellKnownCacheEntry>({ max: 512 })
@ -73,6 +76,86 @@ function pubkeyHexFromWellKnownNamesValue(v: unknown): string | null { @@ -73,6 +76,86 @@ function pubkeyHexFromWellKnownNamesValue(v: unknown): string | null {
return null
}
function npubFromHexPubkey(hex: string): string | null {
try {
return nip19.npubEncode(normalizeHexPubkey(hex))
} catch {
return null
}
}
export type TNip05NamePubkeyEntry = { name: string; pubkey: string }
/**
* Parse one `names` row. Supports:
* - standard: `username` hex or npub
* - inverted: hex or npub key `username` label
*/
export function parseNip05NamePubkeyEntry(key: string, value: unknown): TNip05NamePubkeyEntry | null {
const hexFromValue = pubkeyHexFromWellKnownNamesValue(value)
if (hexFromValue) {
return { name: key, pubkey: hexFromValue }
}
const hexFromKey = pubkeyHexFromWellKnownNamesValue(key)
if (!hexFromKey) return null
const label = asNip05LookupString(value).trim()
if (!label || pubkeyHexFromWellKnownNamesValue(label)) return null
return { name: label, pubkey: hexFromKey }
}
function resolveNamesEntry(
names: Record<string, unknown>,
nip05Name: string,
userPubkeyHex?: string
): TNip05NamePubkeyEntry | null {
const want = nip05Name.trim()
if (!want) return null
const direct = getNamesEntryRaw(names, want)
if (direct != null) {
const hex = pubkeyHexFromWellKnownNamesValue(direct)
if (hex) return { name: want, pubkey: hex }
}
for (const [key, v] of Object.entries(names)) {
const entry = parseNip05NamePubkeyEntry(key, v)
if (entry && entry.name.toLowerCase() === want.toLowerCase()) return entry
}
if (!userPubkeyHex || !isValidPubkey(userPubkeyHex)) return null
const user = normalizeHexPubkey(userPubkeyHex)
const userNpub = npubFromHexPubkey(user)
if (userNpub) {
for (const [key, v] of Object.entries(names)) {
if (key.toLowerCase() !== userNpub.toLowerCase()) continue
const entry = parseNip05NamePubkeyEntry(key, v)
if (entry && hexPubkeysEqual(entry.pubkey, user)) return entry
const hex = pubkeyHexFromWellKnownNamesValue(v)
if (hex && hexPubkeysEqual(hex, user)) return { name: want, pubkey: hex }
}
}
for (const [key, v] of Object.entries(names)) {
const entry = parseNip05NamePubkeyEntry(key, v)
if (entry && hexPubkeysEqual(entry.pubkey, user)) return entry
}
return null
}
function nip05LocalNameForPubkeyFromNames(
names: Record<string, unknown> | undefined,
userPubkeyHex: string
): string | undefined {
if (!names || typeof names !== 'object') return undefined
const user = normalizeHexPubkey(userPubkeyHex)
for (const [key, v] of Object.entries(names)) {
const entry = parseNip05NamePubkeyEntry(key, v)
if (entry && hexPubkeysEqual(entry.pubkey, user)) return entry.name
}
return undefined
}
function getNamesEntryRaw(names: Record<string, unknown>, nip05Name: string): string | undefined {
if (!nip05Name || typeof names !== 'object' || names == null) return undefined
const asTrimmedString = (x: unknown): string | undefined =>
@ -90,7 +173,8 @@ function pickRelayListForPubkey( @@ -90,7 +173,8 @@ function pickRelayListForPubkey(
relays: Record<string, unknown> | undefined,
userPubkeyHex: string,
listedRaw: string,
nip05LocalName: string
nip05LocalName: string,
names?: Record<string, unknown>
): unknown {
if (!relays || typeof relays !== 'object') return undefined
const user = normalizeHexPubkey(userPubkeyHex)
@ -98,14 +182,13 @@ function pickRelayListForPubkey( @@ -98,14 +182,13 @@ function pickRelayListForPubkey(
keysToTry.add(user)
keysToTry.add(userPubkeyHex.trim())
keysToTry.add(listedRaw.trim())
const userNpub = npubFromHexPubkey(user)
if (userNpub) keysToTry.add(userNpub)
const listedHex = pubkeyHexFromWellKnownNamesValue(listedRaw)
if (listedHex) {
keysToTry.add(listedHex)
try {
keysToTry.add(nip19.npubEncode(listedHex))
} catch {
/* ignore */
}
const listedNpub = npubFromHexPubkey(listedHex)
if (listedNpub) keysToTry.add(listedNpub)
}
for (const k of keysToTry) {
if (!k) continue
@ -118,8 +201,8 @@ function pickRelayListForPubkey( @@ -118,8 +201,8 @@ function pickRelayListForPubkey(
if (Array.isArray(v)) return v
}
}
/** Some hosts key `relays` by NIP-05 local part (non-standard); NIP recommends pubkey keys. */
const local = nip05LocalName.trim()
const local =
nip05LocalName.trim() || nip05LocalNameForPubkeyFromNames(names, userPubkeyHex) || ''
if (local) {
const want = local.toLowerCase()
for (const k of Object.keys(relays)) {
@ -157,14 +240,17 @@ async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05 @@ -157,14 +240,17 @@ async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05
const names = json.names as Record<string, unknown> | undefined
if (!names || typeof names !== 'object') return result
const listedRaw = getNamesEntryRaw(names, nip05Name)
if (listedRaw == null) return result
const listedHex = pubkeyHexFromWellKnownNamesValue(listedRaw)
if (!listedHex || !hexPubkeysEqual(listedHex, userHex)) return result
const resolved = resolveNamesEntry(names, nip05Name, userHex)
if (!resolved || !hexPubkeysEqual(resolved.pubkey, userHex)) return result
const relays = json.relays as Record<string, unknown> | undefined
const relayList = pickRelayListForPubkey(relays, userHex, listedRaw, nip05Name)
const relayList = pickRelayListForPubkey(
relays,
userHex,
resolved.pubkey,
resolved.name,
names
)
return { ...result, isVerified: true, relays: Array.isArray(relayList) ? (relayList as string[]) : undefined }
}
@ -192,39 +278,85 @@ export function getWellKnownNip05Url(domain: string, name?: string): string { @@ -192,39 +278,85 @@ export function getWellKnownNip05Url(domain: string, name?: string): string {
return url.toString()
}
/**
* Fetch `/.well-known/nostr.json` in the browser without tripping third-party CORS:
* when `VITE_PROXY_SERVER` is set (production), use same-origin `/sites/?url=…` like OG preview.
*/
async function fetchWellKnownNostrJsonOnce(
domain: string,
nameInQuery: string | undefined
function isWellKnownNostrJsonDocument(data: unknown): data is Record<string, unknown> {
if (!data || typeof data !== 'object' || Array.isArray(data)) return false
const names = (data as Record<string, unknown>).names
return typeof names === 'object' && names != null && !Array.isArray(names)
}
async function readWellKnownNostrJsonResponse(res: Response): Promise<Record<string, unknown> | null> {
try {
const data: unknown = await res.json()
return isWellKnownNostrJsonDocument(data) ? data : null
} catch {
return null
}
}
async function fetchWellKnownNostrJsonDirect(targetUrl: string): Promise<Record<string, unknown> | null> {
try {
const res = await fetchWithTimeout(targetUrl, {
credentials: 'omit',
headers: {
Accept: 'application/nostr+json, application/json;q=0.9, text/plain;q=0.8, */*;q=0.1'
},
timeoutMs: 15_000,
mode: 'cors'
})
if (!res.ok) return null
return readWellKnownNostrJsonResponse(res)
} catch {
return null
}
}
async function fetchWellKnownNostrJsonViaProxy(
targetUrl: string,
proxyServer: string
): Promise<Record<string, unknown> | null> {
const targetUrl = getWellKnownNip05Url(domain, nameInQuery)
const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim()
const useProxy = Boolean(proxyServer && !isSitesProxyUnavailableThisSession())
const fetchUrl = useProxy ? buildViteProxySitesFetchUrl(targetUrl, proxyServer!) : targetUrl
const fetchUrl = buildViteProxySitesFetchUrl(targetUrl, proxyServer)
try {
const res = await fetchWithTimeout(fetchUrl, {
credentials: 'omit',
headers: {
Accept: 'application/nostr+json, application/json;q=0.9, text/plain;q=0.8, */*;q=0.1'
},
timeoutMs: 15_000
timeoutMs: 15_000,
mode: 'cors'
})
/** NIP-05: well-known MUST NOT redirect; following redirects can land on unrelated JSON. */
if (res.redirected || !res.ok) {
if (useProxy && !res.redirected) markSitesProxyUnavailableFromHttpStatus(res.status)
if (!res.ok) {
if (!res.redirected) markSitesProxyUnavailableFromHttpStatus(res.status)
return null
}
if (useProxy) clearSitesProxyUnavailableThisSession()
const data: unknown = await res.json()
return data && typeof data === 'object' && !Array.isArray(data) ? (data as Record<string, unknown>) : null
const json = await readWellKnownNostrJsonResponse(res)
if (json) clearSitesProxyUnavailableThisSession()
return json
} catch {
return null
}
}
/**
* Fetch `/.well-known/nostr.json` in the browser without tripping third-party CORS:
* when `VITE_PROXY_SERVER` is set (production), use same-origin `/sites/?url=…` like OG preview.
*/
async function fetchWellKnownNostrJsonOnce(
domain: string,
nameInQuery: string | undefined
): Promise<Record<string, unknown> | null> {
const targetUrl = getWellKnownNip05Url(domain, nameInQuery)
const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim()
const useProxy = Boolean(proxyServer && !isSitesProxyUnavailableThisSession())
if (useProxy) {
const viaProxy = await fetchWellKnownNostrJsonViaProxy(targetUrl, proxyServer!)
if (viaProxy) return viaProxy
return fetchWellKnownNostrJsonDirect(targetUrl)
}
return fetchWellKnownNostrJsonDirect(targetUrl)
}
/** Uncached network: optional `?name=` then full document. */
async function fetchWellKnownNostrJsonNetwork(
domain: string,
@ -234,13 +366,13 @@ async function fetchWellKnownNostrJsonNetwork( @@ -234,13 +366,13 @@ async function fetchWellKnownNostrJsonNetwork(
const withQuery =
trimmedName.length > 0 ? await fetchWellKnownNostrJsonOnce(domain, trimmedName) : null
if (withQuery && typeof withQuery.names === 'object' && withQuery.names != null) {
if (getNamesEntryRaw(withQuery.names as Record<string, unknown>, trimmedName) != null) {
if (resolveNamesEntry(withQuery.names as Record<string, unknown>, trimmedName) != null) {
return withQuery
}
}
const full = await fetchWellKnownNostrJsonOnce(domain, undefined)
if (full && trimmedName && typeof full.names === 'object' && full.names != null) {
if (getNamesEntryRaw(full.names as Record<string, unknown>, trimmedName) != null) {
if (resolveNamesEntry(full.names as Record<string, unknown>, trimmedName) != null) {
return full
}
}
@ -254,13 +386,19 @@ async function getOrFetchWellKnownJsonForDomain( @@ -254,13 +386,19 @@ async function getOrFetchWellKnownJsonForDomain(
const key = normalizeNip05Domain(domain)
if (!key) return null
if (wellKnownJsonByDomain.has(key)) {
return wellKnownJsonByDomain.get(key)!.json
const cached = wellKnownJsonByDomain.get(key)!
if (cached.schema === WELL_KNOWN_CACHE_SCHEMA && cached.json && isWellKnownNostrJsonDocument(cached.json)) {
return cached.json
}
wellKnownJsonByDomain.delete(key)
}
let inflight = wellKnownDomainInFlight.get(key)
if (!inflight) {
inflight = fetchWellKnownNostrJsonNetwork(key, nameHint).then((json) => {
wellKnownJsonByDomain.set(key, { json })
wellKnownDomainInFlight.delete(key)
if (isWellKnownNostrJsonDocument(json)) {
wellKnownJsonByDomain.set(key, { json, schema: WELL_KNOWN_CACHE_SCHEMA })
}
return json
})
wellKnownDomainInFlight.set(key, inflight)
@ -276,19 +414,36 @@ async function fetchWellKnownNostrJson(domain: string, name?: string): Promise<R @@ -276,19 +414,36 @@ async function fetchWellKnownNostrJson(domain: string, name?: string): Promise<R
}
export async function fetchPubkeysFromDomain(domain: string): Promise<string[]> {
const entries = await fetchNip05NamePubkeysFromDomain(domain)
return entries.map((e) => e.pubkey)
}
export function parseNip05NamePubkeysFromWellKnownJson(
json: Record<string, unknown>
): Array<{ name: string; pubkey: string }> {
const names = json.names as Record<string, unknown> | undefined
if (!names || typeof names !== 'object') return []
const out: Array<{ name: string; pubkey: string }> = []
const seen = new Set<string>()
for (const [key, v] of Object.entries(names)) {
const entry = parseNip05NamePubkeyEntry(key, v)
if (!entry || !isValidPubkey(entry.pubkey)) continue
const dedupe = `${entry.name}:${entry.pubkey}`
if (seen.has(dedupe)) continue
seen.add(dedupe)
out.push(entry)
}
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}
export async function fetchNip05NamePubkeysFromDomain(
domain: string
): Promise<Array<{ name: string; pubkey: string }>> {
try {
const json = await fetchWellKnownNostrJson(domain)
if (!json) return []
const pubkeySet = new Set<string>()
const out: string[] = []
for (const v of Object.values((json.names as Record<string, unknown>) || {})) {
const hex = pubkeyHexFromWellKnownNamesValue(v)
if (!hex || !isValidPubkey(hex)) continue
if (pubkeySet.has(hex)) continue
pubkeySet.add(hex)
out.push(hex)
}
return out
return parseNip05NamePubkeysFromWellKnownJson(json)
} catch (error) {
logger.error('Error fetching pubkeys from domain', { error, domain })
return []

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { Favicon } from '@/components/Favicon'
import Nip05DomainEmptyState from '@/components/Nip05DomainPanel/Nip05DomainEmptyState'
import type { TNoteListRef } from '@/components/NoteList'
import NormalFeed from '@/components/NormalFeed'
import { RefreshButton } from '@/components/RefreshButton'
@ -22,7 +23,7 @@ import { @@ -22,7 +23,7 @@ import {
buildAlexandriaEventsUrlForHashtagParam
} from '@/lib/alexandria-events-search-url'
import { compareEventsForDTagQuery, eventMatchesDTagLooseQuery } from '@/lib/dtag-search'
import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05'
import { fetchPubkeysFromDomain } from '@/lib/nip05'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useSecondaryPage } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -358,13 +359,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -358,13 +359,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
let content: React.ReactNode = null
if (data?.type === 'domain' && subRequests.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>
)
content = <Nip05DomainEmptyState domain={data.domain} />
} else if (data) {
content =
data.type === 'dtag' && data.dtag ? (

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

@ -1,10 +1,9 @@ @@ -1,10 +1,9 @@
import { Favicon } from '@/components/Favicon'
import ProfileList from '@/components/ProfileList'
import ProfileListByNip05Domain from '@/components/Nip05DomainPanel/ProfileListByNip05Domain'
import { ProfileListBySearch } from '@/components/ProfileListBySearch'
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url'
import { fetchPubkeysFromDomain } from '@/lib/nip05'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -54,7 +53,7 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -54,7 +53,7 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
<ProfileListBySearch search={data.id} alexandriaEmptyHref={profileSearchAlexandriaHref} />
)
} else if (data?.type === 'domain') {
content = <ProfileListByDomain domain={data.id} />
content = <ProfileListByNip05Domain domain={data.id} />
}
return (
@ -73,17 +72,3 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -73,17 +72,3 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
})
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} />
}

Loading…
Cancel
Save