Browse Source

more fix menus

fix profile loading
add trusted NIP-05
imwald
Silberengel 2 weeks ago
parent
commit
dff5eca0d5
  1. 54
      src/components/Nip05AffiliationBadges/index.tsx
  2. 2
      src/components/Nip05DomainPanel/ProfileListByNip05Domain.tsx
  3. 58
      src/components/Note/index.tsx
  4. 38
      src/components/NoteAuthorMetaLine/index.tsx
  5. 109
      src/components/NoteOptions/useMenuActions.tsx
  6. 28
      src/components/NoteStats/index.tsx
  7. 87
      src/components/ProfileList/index.tsx
  8. 33
      src/components/ReplyNote/index.tsx
  9. 10
      src/components/Username/index.tsx
  10. 25
      src/constants.ts
  11. 25
      src/hooks/useFetchProfile.tsx
  12. 57
      src/hooks/useThreadNotificationMenuState.ts
  13. 51
      src/hooks/useVerifiedNip05Affiliations.ts
  14. 4
      src/i18n/locales/cs.ts
  15. 6
      src/i18n/locales/de.ts
  16. 6
      src/i18n/locales/en.ts
  17. 4
      src/i18n/locales/es.ts
  18. 4
      src/i18n/locales/fr.ts
  19. 4
      src/i18n/locales/nl.ts
  20. 4
      src/i18n/locales/pl.ts
  21. 4
      src/i18n/locales/ru.ts
  22. 4
      src/i18n/locales/tr.ts
  23. 4
      src/i18n/locales/zh.ts
  24. 41
      src/lib/event-ingest-filter.test.ts
  25. 29
      src/lib/event-ingest-filter.ts
  26. 51
      src/lib/nip05-affiliation.ts
  27. 16
      src/lib/nip05-well-known.test.ts
  28. 23
      src/lib/nip05.ts

54
src/components/Nip05AffiliationBadges/index.tsx

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
import { useFetchProfile } from '@/hooks'
import { useVerifiedNip05Affiliations } from '@/hooks/useVerifiedNip05Affiliations'
import { userIdToPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
export default function Nip05AffiliationBadges({
userId,
pubkey: pubkeyProp,
nip05: nip05Prop,
nip05List: nip05ListProp,
className
}: {
/** Hex or npub — loads kind 0 for NIP-05 when `nip05` / `nip05List` omitted. */
userId?: string
pubkey?: string
nip05?: string
nip05List?: string[]
className?: string
}) {
const { t } = useTranslation()
const pubkey = pubkeyProp ?? (userId ? userIdToPubkey(userId) : '')
const { profile } = useFetchProfile(
nip05Prop === undefined && nip05ListProp === undefined && pubkey ? pubkey : undefined
)
const nip05 = nip05Prop ?? profile?.nip05
const nip05List = nip05ListProp ?? profile?.nip05List
const affiliations = useVerifiedNip05Affiliations(pubkey, nip05, nip05List)
if (affiliations.length === 0) return null
return (
<span
className={cn('inline-flex shrink-0 items-center gap-0.5', className)}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{affiliations.map((aff) => {
const label = aff.label ?? aff.domain
return (
<span
key={aff.domain}
role="img"
aria-label={t('Verified NIP-05 affiliation', { domain: label })}
title={t('Verified NIP-05 affiliation', { domain: label })}
className="inline-flex size-[1.05em] items-center justify-center text-sm leading-none select-none grayscale contrast-125 opacity-90"
>
{aff.emoji}
</span>
)
})}
</span>
)
}

2
src/components/Nip05DomainPanel/ProfileListByNip05Domain.tsx

@ -66,7 +66,7 @@ export default function ProfileListByNip05Domain({ domain }: { domain: string }) @@ -66,7 +66,7 @@ export default function ProfileListByNip05Domain({ domain }: { domain: string })
<div className="px-4 pt-2">
{visible.map(({ name, pubkey }) => (
<div
key={pubkey}
key={`${pubkey}:${name}`}
className="flex min-w-0 items-center gap-2 border-b border-border/40 py-1 last:border-0"
>
{name && name !== '_' ? (

58
src/components/Note/index.tsx

@ -46,11 +46,10 @@ import { CreateHighlightContext } from './CreateHighlightContext' @@ -46,11 +46,10 @@ import { CreateHighlightContext } from './CreateHighlightContext'
import SelectionHighlightTrigger from './SelectionHighlightTrigger'
import AudioPlayer from '../AudioPlayer'
import WebPreview from '../WebPreview'
import ClientTag from '../ClientTag'
import EventPowLabel from '../EventPowLabel'
import NoteAuthorMetaLine from '../NoteAuthorMetaLine'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions'
import EventPowLabel from '../EventPowLabel'
import ParentNotePreview from '../ParentNotePreview'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
@ -683,7 +682,6 @@ export default function Note({ @@ -683,7 +682,6 @@ export default function Note({
className={`max-w-[min(12rem,40vw)] shrink font-semibold truncate ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
<ClientTag event={event} />
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{t(notificationReactionSummaryKey(reactionDisplay))}
</span>
@ -718,7 +716,6 @@ export default function Note({ @@ -718,7 +716,6 @@ export default function Note({
>
{t('Imwald synthetic event')}
</span>
<ClientTag event={event} />
</div>
</div>
</>
@ -730,45 +727,18 @@ export default function Note({ @@ -730,45 +727,18 @@ export default function Note({
maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={deferAuthorAvatar}
/>
{showFull ? (
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
<Username
userId={event.pubkey}
className={`font-semibold flex truncate ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
<ClientTag event={event} />
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
/>
<EventPowLabel event={event} />
</div>
</div>
) : (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-0.5 overflow-hidden">
<Username
userId={event.pubkey}
className={`max-w-[min(12rem,40vw)] shrink font-semibold truncate ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
<ClientTag event={event} />
<span className="inline-flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
/>
<EventPowLabel event={event} />
</span>
</div>
)}
<NoteAuthorMetaLine
userId={event.pubkey}
timestamp={event.created_at}
powEvent={event}
usernameClassName={
size === 'small'
? 'max-w-[min(12rem,40vw)] text-sm'
: 'max-w-[min(16rem,50vw)]'
}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
timestampShort={isSmallScreen}
/>
</>
)}
</div>

38
src/components/NoteAuthorMetaLine/index.tsx

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
import Nip05AffiliationBadges from '@/components/Nip05AffiliationBadges'
import { FormattedTimestamp } from '@/components/FormattedTimestamp'
import EventPowLabel from '@/components/EventPowLabel'
import Username from '@/components/Username'
import { cn } from '@/lib/utils'
import type { Event } from 'nostr-tools'
/** Username, relative time, verified NIP-05 affiliation badges, optional PoW — one header row. */
export default function NoteAuthorMetaLine({
userId,
timestamp,
powEvent,
usernameClassName,
skeletonClassName,
timestampShort = false
}: {
userId: string
timestamp: number
powEvent?: Event
usernameClassName?: string
skeletonClassName?: string
timestampShort?: boolean
}) {
return (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-0.5 overflow-hidden">
<Username
userId={userId}
className={cn('shrink font-semibold truncate', usernameClassName)}
skeletonClassName={skeletonClassName}
/>
<span className="inline-flex min-w-0 shrink-0 items-center gap-x-1.5 text-sm text-muted-foreground">
<FormattedTimestamp timestamp={timestamp} className="shrink-0" short={timestampShort} />
<Nip05AffiliationBadges userId={userId} />
{powEvent ? <EventPowLabel event={powEvent} /> : null}
</span>
</div>
)
}

109
src/components/NoteOptions/useMenuActions.tsx

@ -1,7 +1,10 @@ @@ -1,7 +1,10 @@
import { ExtendedKind, READ_ALOUD_KINDS } from '@/constants'
import ClientTag from '@/components/ClientTag'
import Nip05 from '@/components/Nip05'
import {
getNoteBech32Id,
getReplaceableCoordinateFromEvent,
getUsingClient,
isProtectedEvent,
isReplaceableEvent,
getRootEventHexId
@ -47,6 +50,7 @@ import { useMuteList } from '@/contexts/mute-list-context' @@ -47,6 +50,7 @@ import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
import { useBookmarksOptional } from '@/providers/bookmarks-context'
import { useThreadNotificationMenuState } from '@/hooks/useThreadNotificationMenuState'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service'
import { eventService } from '@/services/client.service'
@ -168,6 +172,7 @@ export function useMenuActions({ @@ -168,6 +172,7 @@ export function useMenuActions({
checkLogin
} = useNostr()
const bookmarksContext = useBookmarksOptional()
const { threadFollowed, threadMuted, threadWatch } = useThreadNotificationMenuState(event)
const { addBookmark, removeBookmark } = bookmarksContext ?? {
addBookmark: async () => {},
removeBookmark: async () => false
@ -1114,9 +1119,40 @@ export function useMenuActions({ @@ -1114,9 +1119,40 @@ export function useMenuActions({
!isDiscussion &&
!isReplyToDiscussion
const advancedAuthorMetaRows: SubMenuAction[] = []
if (getUsingClient(event)) {
advancedAuthorMetaRows.push({
label: (
<div className="flex flex-col gap-0.5 py-0.5">
<span className="text-xs font-medium text-muted-foreground">{t('Posted via')}</span>
<ClientTag event={event} />
</div>
),
onClick: () => {},
className: 'cursor-default focus:bg-transparent data-[highlighted]:bg-transparent'
})
}
advancedAuthorMetaRows.push({
label: (
<div
className="flex flex-col gap-0.5 py-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<span className="text-xs font-medium text-muted-foreground">NIP-05</span>
<Nip05 pubkey={event.pubkey} />
</div>
),
onClick: () => {},
className: 'cursor-default focus:bg-transparent data-[highlighted]:bg-transparent',
filterHaystack: 'nip05'
})
const advancedSubMenu: SubMenuAction[] = [
...advancedAuthorMetaRows,
{
label: t('Copy event ID'),
separator: advancedAuthorMetaRows.length > 0,
onClick: () => {
navigator.clipboard.writeText(getNoteBech32Id(event))
closeDrawer()
@ -1339,6 +1375,70 @@ export function useMenuActions({ @@ -1339,6 +1375,70 @@ export function useMenuActions({
}
}
const savesGroupStartIndex = actions.length
const savesGroupNeedsSeparator = savesGroupStartIndex > 0
if (threadWatch && pubkey) {
actions.push({
icon: Bell,
label: threadFollowed ? t('Unfollow thread notifications') : t('Follow this'),
separator: savesGroupNeedsSeparator,
onClick: () => {
closeDrawer()
void checkLogin(async () => {
try {
if (threadFollowed) {
const ok = await threadWatch.unfollowThreadForNotifications(event)
if (ok) {
toast.success(t('Unfollowed thread notifications'))
} else {
toast.error(t('Thread notification list update failed'))
}
} else {
await threadWatch.followThreadForNotifications(event)
toast.success(t('Following thread for notifications'))
}
} catch (err) {
toast.error(
t('Thread notification list update failed') +
': ' +
(err instanceof Error ? err.message : String(err))
)
}
})
}
})
actions.push({
icon: BellOff,
label: threadMuted ? t('Unmute thread notifications') : t('Mute this'),
className: 'text-destructive focus:text-destructive',
onClick: () => {
closeDrawer()
void checkLogin(async () => {
try {
if (threadMuted) {
const ok = await threadWatch.unmuteThreadForNotifications(event)
if (ok) {
toast.success(t('Unmuted thread notifications'))
} else {
toast.error(t('Thread notification list update failed'))
}
} else {
await threadWatch.muteThreadForNotifications(event)
toast.success(t('Muted thread for notifications'))
}
} catch (err) {
toast.error(
t('Thread notification list update failed') +
': ' +
(err instanceof Error ? err.message : String(err))
)
}
})
}
})
}
if (pubkey && event.pubkey === pubkey) {
actions.push({
icon: Pin,
@ -1346,7 +1446,7 @@ export function useMenuActions({ @@ -1346,7 +1446,7 @@ export function useMenuActions({
onClick: () => {
handlePinNote()
},
separator: true
separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator
})
} else if (pubkey && event.pubkey !== pubkey && bookmarksContext) {
actions.push({
@ -1374,7 +1474,7 @@ export function useMenuActions({ @@ -1374,7 +1474,7 @@ export function useMenuActions({
}
})
},
separator: true
separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator
})
}
@ -1432,7 +1532,10 @@ export function useMenuActions({ @@ -1432,7 +1532,10 @@ export function useMenuActions({
seenOnRelays,
push,
currentPrimaryPage,
isReplyToDiscussion
isReplyToDiscussion,
threadWatch,
threadFollowed,
threadMuted
])
return menuActions

28
src/components/NoteStats/index.tsx

@ -10,14 +10,12 @@ import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot @@ -10,14 +10,12 @@ import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot
import { normalizeAnyRelayUrl } from '@/lib/url'
import { Event } from 'nostr-tools'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import NotificationThreadWatchButtons from '../NotificationThreadWatchButtons'
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider'
import { LikeButtonWithStats } from './LikeButton'
import { ReplyButtonWithStats } from './ReplyButton'
import { RepostButtonWithStats } from './RepostButton'
import { ZapButtonWithStats } from './ZapButton'
/** One column in the note action bar; default equal flex, or sized via `className` (discussions need wider vote slot). */
/** One slot in the note action bar; left-aligned with gap spacing (not equal-width columns). */
function NoteStatsBarItem({
children,
className
@ -28,7 +26,7 @@ function NoteStatsBarItem({ @@ -28,7 +26,7 @@ function NoteStatsBarItem({
return (
<div
className={cn(
'flex min-w-0 flex-1 basis-0 items-center justify-center overflow-hidden [&>*]:min-w-0 [&>*]:max-w-full',
'flex shrink-0 items-center overflow-hidden [&>*]:min-w-0 [&>*]:max-w-full',
className
)}
>
@ -140,15 +138,12 @@ export default function NoteStats({ @@ -140,15 +138,12 @@ export default function NoteStats({
statsFetchRelayScopeKey
])
const watch = useNotificationThreadWatchOptional()
const showThreadWatchButtons = Boolean(watch && pubkey)
/** Kind 11 / 1111 under a discussion: up+down votes need more width than a single like button. */
const isDiscussionBar = isDiscussion || isReplyToDiscussion
const compactBarItem = isDiscussionBar ? 'shrink-0 flex-none basis-auto' : undefined
const voteBarItem = isDiscussionBar ? 'min-w-[6.75rem] flex-[2] basis-28 sm:min-w-[7.25rem]' : undefined
const voteBarItem = isDiscussionBar ? 'min-w-[6.75rem] sm:min-w-[7.25rem]' : undefined
const barItems: ReactNode[] = [
<NoteStatsBarItem key="reply" className={compactBarItem}>
<NoteStatsBarItem key="reply">
<ReplyButtonWithStats event={event} noteStats={noteStats} />
</NoteStatsBarItem>
]
@ -174,22 +169,12 @@ export default function NoteStats({ @@ -174,22 +169,12 @@ export default function NoteStats({
if (!isRssArticleRoot) {
barItems.push(
<NoteStatsBarItem key="tip" className={compactBarItem}>
<NoteStatsBarItem key="tip">
<ZapButtonWithStats event={event} noteStats={noteStats} />
</NoteStatsBarItem>
)
}
if (!isRssArticleRoot && showThreadWatchButtons) {
barItems.push(
<NoteStatsBarItem key="thread-watch" className={compactBarItem}>
<div className="flex items-center justify-center gap-0.5">
<NotificationThreadWatchButtons event={event} />
</div>
</NoteStatsBarItem>
)
}
return (
<div
ref={containerRef}
@ -199,7 +184,8 @@ export default function NoteStats({ @@ -199,7 +184,8 @@ export default function NoteStats({
>
<div
className={cn(
'flex w-full min-w-0 items-stretch [&_svg]:size-[15px] [&_button]:min-h-9 [&_button]:max-w-full [&_button]:px-1 sm:[&_button]:px-1.5',
'flex w-full min-w-0 flex-wrap items-center justify-start gap-x-3 gap-y-1 sm:gap-x-4',
'[&_svg]:size-[15px] [&_button]:min-h-9 [&_button]:max-w-full [&_button]:px-1 sm:[&_button]:px-1.5',
loading ? 'animate-pulse' : '',
classNames?.buttonBar
)}

87
src/components/ProfileList/index.tsx

@ -1,9 +1,17 @@ @@ -1,9 +1,17 @@
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import client from '@/services/client.service'
import type { TProfile } from '@/types'
import { useEffect, useMemo, useRef, useState } from 'react'
import UserItem from '../UserItem'
const PROFILE_CHUNK = 80
export default function ProfileList({ pubkeys }: { pubkeys: string[] }) {
const [visiblePubkeys, setVisiblePubkeys] = useState<string[]>([])
const [profilesByPubkey, setProfilesByPubkey] = useState<Map<string, TProfile>>(() => new Map())
const bottomRef = useRef<HTMLDivElement>(null)
const loadedRef = useRef<Set<string>>(new Set())
const batchGenRef = useRef(0)
const pubkeysKey = useMemo(() => pubkeys.join('\u0001'), [pubkeys])
useEffect(() => {
@ -35,11 +43,84 @@ export default function ProfileList({ pubkeys }: { pubkeys: string[] }) { @@ -35,11 +43,84 @@ export default function ProfileList({ pubkeys }: { pubkeys: string[] }) {
}
}, [visiblePubkeys, pubkeysKey, pubkeys])
const visibleHexPubkeysKey = useMemo(
() =>
visiblePubkeys
.filter((pk) => pk.length === 64 && /^[0-9a-f]{64}$/i.test(pk))
.map((pk) => pk.toLowerCase())
.join('\u0001'),
[visiblePubkeys]
)
useEffect(() => {
const need = visibleHexPubkeysKey
.split('\u0001')
.filter(Boolean)
.filter((pk) => !loadedRef.current.has(pk))
if (need.length === 0) return
const gen = ++batchGenRef.current
need.forEach((pk) => loadedRef.current.add(pk))
void (async () => {
const chunks: string[][] = []
for (let i = 0; i < need.length; i += PROFILE_CHUNK) {
chunks.push(need.slice(i, i + PROFILE_CHUNK))
}
const settled = await Promise.allSettled(
chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk))
)
if (gen !== batchGenRef.current) return
setProfilesByPubkey((prev) => {
const next = new Map(prev)
settled.forEach((res, idx) => {
const chunk = chunks[idx]!
if (res.status === 'rejected') {
chunk.forEach((pk) => loadedRef.current.delete(pk))
return
}
for (const p of res.value) {
const pkNorm = p.pubkey.toLowerCase()
next.set(pkNorm, { ...p, pubkey: pkNorm })
}
for (const pk of chunk) {
const pkNorm = pk.toLowerCase()
if (!next.has(pkNorm)) {
next.set(pkNorm, {
pubkey: pkNorm,
npub: pubkeyToNpub(pkNorm) ?? '',
username: formatPubkey(pkNorm),
batchPlaceholder: true
})
}
}
})
return next
})
})()
}, [visibleHexPubkeysKey])
useEffect(() => {
batchGenRef.current += 1
loadedRef.current.clear()
setProfilesByPubkey(new Map())
}, [pubkeysKey])
return (
<div className="px-4 pt-2">
{visiblePubkeys.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{visiblePubkeys.map((pubkey, index) => {
const pkNorm = pubkey.length === 64 ? pubkey.toLowerCase() : pubkey
const prefetchedProfile = profilesByPubkey.get(pkNorm)
return (
<UserItem
key={`${index}-${pubkey}`}
pubkey={pubkey}
prefetchedProfile={prefetchedProfile}
deferRemoteAvatar={false}
/>
)
})}
{pubkeys.length > visiblePubkeys.length && <div ref={bottomRef} />}
</div>
)

33
src/components/ReplyNote/index.tsx

@ -25,19 +25,15 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -25,19 +25,15 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ClientTag from '../ClientTag'
import EventPowLabel from '../EventPowLabel'
import Collapsible from '../Collapsible'
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle'
import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteAuthorMetaLine from '../NoteAuthorMetaLine'
import NoteOptions from '../NoteOptions'
import NoteStats from '../NoteStats'
import ParentNotePreview from '../ParentNotePreview'
import WebPreview from '../WebPreview'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import Superchat from '../Note/Superchat'
import Zap from '../Note/Zap'
import MoneroTip from '../Note/MoneroTip'
@ -126,25 +122,14 @@ export default function ReplyNote({ @@ -126,25 +122,14 @@ export default function ReplyNote({
maxFileSizeKb={2048}
deferRemoteAvatar={false}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<Username
userId={headerUserId}
className="truncate text-sm font-semibold text-muted-foreground hover:text-foreground"
skeletonClassName="h-3"
/>
<ClientTag event={event} />
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={headerUserId} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
/>
<EventPowLabel event={event} />
</div>
</div>
<NoteAuthorMetaLine
userId={headerUserId}
timestamp={event.created_at}
powEvent={event}
usernameClassName="max-w-[min(12rem,40vw)] text-sm text-muted-foreground hover:text-foreground"
skeletonClassName="h-3"
timestampShort={isSmallScreen}
/>
</div>
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div>

10
src/components/Username/index.tsx

@ -33,7 +33,15 @@ export default function Username({ @@ -33,7 +33,15 @@ export default function Username({
const { profile: fetchedProfile, isFetching } = useFetchProfile(userId)
const profile = useMemo(() => {
const idPk = userId ? userIdToPubkey(userId) : ''
if (prefetchedProfile && idPk && prefetchedProfile.pubkey === idPk) {
if (
prefetchedProfile &&
idPk &&
prefetchedProfile.pubkey.toLowerCase() === idPk.toLowerCase()
) {
const fetchedOk = fetchedProfile && !fetchedProfile.batchPlaceholder
const prefetchedOk = !prefetchedProfile.batchPlaceholder
if (fetchedOk) return fetchedProfile
if (prefetchedOk) return prefetchedProfile
return fetchedProfile ?? prefetchedProfile
}
return fetchedProfile

25
src/constants.ts

@ -276,6 +276,31 @@ export const PROFILE_BATCH_POST_COOLDOWN_MS = 90_000 @@ -276,6 +276,31 @@ export const PROFILE_BATCH_POST_COOLDOWN_MS = 90_000
*/
export const PROFILE_SECONDARY_PANEL_DEFER_MS = 120_000
/**
* Trusted NIP-05 domains shown as compact affiliation badges beside usernames (verified only).
* Add entries here to recognize more community registries.
*/
export type TNip05AffiliationDomain = {
/** Host part after `@` in the NIP-05 identifier (lowercase). */
domain: string
/** Badge glyph shown to the right of the display name. */
emoji: string
/** Tooltip / screen-reader label (defaults to `domain`). */
label?: string
}
export const NIP05_AFFILIATION_DOMAINS: readonly TNip05AffiliationDomain[] = [
{ domain: 'nostr.land', emoji: '🌐', label: 'Land' },
{ domain: 'theforest.nostr1.com', emoji: '🌲', label: 'TheForest' },
{ domain: 'gitcitadel.com', emoji: '🛡', label: 'GitCitadel' },
{ domain: 'blog.imwald.eu', emoji: '✍🏼', label: 'Imwald' }
] as const
/** @internal — built from {@link NIP05_AFFILIATION_DOMAINS} for O(1) domain lookup. */
export const NIP05_AFFILIATION_BY_DOMAIN: ReadonlyMap<string, TNip05AffiliationDomain> = new Map(
NIP05_AFFILIATION_DOMAINS.map((entry) => [entry.domain.toLowerCase(), entry])
)
/**
* Hex-id / replaceable-coordinate note lookup ({@link EventService.tryHarderToFetchEvent}, big-relays dataloader).
*/

25
src/hooks/useFetchProfile.tsx

@ -485,7 +485,30 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -485,7 +485,30 @@ export function useFetchProfile(id?: string, skipCache = false) {
setPubkey(extractedPubkey)
setIsFetching(false)
setError(null)
return
const awaitingCancelled = { current: false }
void tryHydrateProfileFromLocalCaches(pkL, false).then((quick) => {
if (awaitingCancelled.current || !quick) return
setProfile(quick)
setIsFetching(false)
setError(null)
processingPubkeyRef.current = extractedPubkey
initializedPubkeysRef.current.add(extractedPubkey)
effectRunCountRef.current.delete(extractedPubkey)
})
const awaitingEscapeTimer = window.setTimeout(() => {
if (awaitingCancelled.current) return
void checkProfile(extractedPubkey, awaitingCancelled)
}, FEED_PROFILE_PENDING_BATCH_ESCAPE_MS)
return () => {
awaitingCancelled.current = true
window.clearTimeout(awaitingEscapeTimer)
if (processingPubkeyRef.current === extractedPubkey) {
processingPubkeyRef.current = null
}
if (extractedPubkey) {
effectRunCountRef.current.delete(extractedPubkey)
}
}
}
// Skip only when this pubkey already has an in-flight fetch (global dedupe + local flag).

57
src/hooks/useThreadNotificationMenuState.ts

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
import { ExtendedKind } from '@/constants'
import {
eventHasExactNotificationThreadWatchRef,
parseThreadWatchListRefs
} from '@/lib/notification-thread-watch'
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider'
import { useNostr } from '@/providers/NostrProvider'
import indexedDb from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react'
/** Local kind 19130 / 19132 lists — thread follow/mute menu state (not the open note’s kind). */
export function useThreadNotificationMenuState(event: Event) {
const { pubkey } = useNostr()
const threadWatch = useNotificationThreadWatchOptional()
const [idbFollowed, setIdbFollowed] = useState(false)
const [idbMuted, setIdbMuted] = useState(false)
const refreshFromIdb = useCallback(async () => {
if (!pubkey) {
setIdbFollowed(false)
setIdbMuted(false)
return
}
const pk = pubkey.trim().toLowerCase()
try {
const [followEv, muteEv] = await Promise.all([
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST),
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST)
])
const followRefs = parseThreadWatchListRefs(followEv ?? undefined)
const muteRefs = parseThreadWatchListRefs(muteEv ?? undefined)
setIdbFollowed(eventHasExactNotificationThreadWatchRef(event, followRefs))
setIdbMuted(eventHasExactNotificationThreadWatchRef(event, muteRefs))
} catch {
setIdbFollowed(false)
setIdbMuted(false)
}
}, [pubkey, event.id, event.kind, event.created_at])
useEffect(() => {
void refreshFromIdb()
}, [
refreshFromIdb,
threadWatch?.eventsIFollowListEvent?.id,
threadWatch?.eventsIMutedListEvent?.id
])
const threadFollowed = threadWatch
? threadWatch.isFollowedForNotifications(event)
: idbFollowed
const threadMuted = threadWatch
? threadWatch.isMutedForNotifications(event)
: idbMuted
return { threadFollowed, threadMuted, threadWatch }
}

51
src/hooks/useVerifiedNip05Affiliations.ts

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
import { NIP05_AFFILIATION_DOMAINS, type TNip05AffiliationDomain } from '@/constants'
import { affiliationNip05CandidatesFromProfile } from '@/lib/nip05-affiliation'
import { verifyNip05 } from '@/lib/nip05'
import { useEffect, useMemo, useState } from 'react'
export function useVerifiedNip05Affiliations(
pubkey: string | undefined,
nip05?: string,
nip05List?: string[]
): readonly TNip05AffiliationDomain[] {
const candidates = useMemo(
() => affiliationNip05CandidatesFromProfile(nip05, nip05List),
[nip05, nip05List]
)
const candidatesKey = useMemo(
() => candidates.map((c) => c.nip05).join('\u0001'),
[candidates]
)
const [verified, setVerified] = useState<readonly TNip05AffiliationDomain[]>([])
useEffect(() => {
if (!pubkey || candidates.length === 0) {
setVerified([])
return
}
let cancelled = false
void (async () => {
const confirmed = new Set<string>()
await Promise.all(
candidates.map(async ({ nip05: nip05Id, affiliation }) => {
const result = await verifyNip05(nip05Id, pubkey)
if (
result.isVerified &&
result.nip05Domain.toLowerCase() === affiliation.domain
) {
confirmed.add(affiliation.domain)
}
})
)
if (cancelled) return
setVerified(
NIP05_AFFILIATION_DOMAINS.filter((entry) => confirmed.has(entry.domain))
)
})()
return () => {
cancelled = true
}
}, [pubkey, candidatesKey])
return verified
}

4
src/i18n/locales/cs.ts

@ -907,8 +907,8 @@ export default { @@ -907,8 +907,8 @@ export default {
Quotes: 'Quotes',
'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

6
src/i18n/locales/de.ts

@ -476,6 +476,8 @@ export default { @@ -476,6 +476,8 @@ export default {
'Add an Account': 'Konto hinzufügen',
'More options': 'Mehr Optionen',
'Add client tag': 'Client-Tag hinzufügen',
'Posted via': 'Veröffentlicht mit',
'Verified NIP-05 affiliation': 'Verifiziert auf {{domain}}',
'Show others this was sent via Imwald': 'Anderen zeigen, dass dies über Imwald gesendet wurde',
'Are you sure you want to logout?': 'Bist du sicher, dass du dich abmelden möchtest?',
'relay sets': 'Relay-Sets',
@ -931,8 +933,8 @@ export default { @@ -931,8 +933,8 @@ export default {
Quotes: 'Zitate',
'Lightning Invoice': 'Lightning-Rechnung',
'Bookmark failed': 'Bookmark fehlgeschlagen',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Diesem Thread folgen',
'Mute this': 'Diesen Thread stummschalten',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

6
src/i18n/locales/en.ts

@ -473,6 +473,8 @@ export default { @@ -473,6 +473,8 @@ export default {
'Add an Account': 'Add an Account',
'More options': 'More options',
'Add client tag': 'Add client tag',
'Posted via': 'Posted via',
'Verified NIP-05 affiliation': 'Verified on {{domain}}',
'Show others this was sent via Imwald': 'Show others this was sent via Imwald',
'Are you sure you want to logout?': 'Are you sure you want to logout?',
'relay sets': 'relay sets',
@ -925,8 +927,8 @@ export default { @@ -925,8 +927,8 @@ export default {
Quotes: 'Quotes',
'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

4
src/i18n/locales/es.ts

@ -911,8 +911,8 @@ export default { @@ -911,8 +911,8 @@ export default {
Quotes: 'Citas',
'Lightning Invoice': 'Factura Lightning',
'Bookmark failed': 'Error al marcar',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

4
src/i18n/locales/fr.ts

@ -911,8 +911,8 @@ export default { @@ -911,8 +911,8 @@ export default {
Quotes: 'Citations',
'Lightning Invoice': 'Facture Lightning',
'Bookmark failed': 'Échec de la mise en favori',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

4
src/i18n/locales/nl.ts

@ -907,8 +907,8 @@ export default { @@ -907,8 +907,8 @@ export default {
Quotes: 'Quotes',
'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

4
src/i18n/locales/pl.ts

@ -908,8 +908,8 @@ export default { @@ -908,8 +908,8 @@ export default {
Quotes: 'Cytaty',
'Lightning Invoice': 'Faktura Lightning',
'Bookmark failed': 'Nie udało się dodać zakładki',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

4
src/i18n/locales/ru.ts

@ -910,8 +910,8 @@ export default { @@ -910,8 +910,8 @@ export default {
Quotes: 'Цитаты',
'Lightning Invoice': 'Lightning-счет',
'Bookmark failed': 'Не удалось добавить закладку',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

4
src/i18n/locales/tr.ts

@ -907,8 +907,8 @@ export default { @@ -907,8 +907,8 @@ export default {
Quotes: 'Quotes',
'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

4
src/i18n/locales/zh.ts

@ -905,8 +905,8 @@ export default { @@ -905,8 +905,8 @@ export default {
Quotes: '引用',
'Lightning Invoice': '闪电发票',
'Bookmark failed': '收藏失败',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

41
src/lib/event-ingest-filter.test.ts

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import type { Event } from 'nostr-tools'
const DRIFT_GITS_SPAM: Event = {
kind: 1,
content:
'sp_4c43bd1d.949ac75f.06.OHCFKDGO2J6TV4KYHAB2JBLIMXHR6RQWVYAGRVBPBUKCH6CPR7JJU3PMG4SBCQA.drift.gits.net',
created_at: 1780215168,
id: '00f077ecb154545e5a5ae98b1fe28db5e30661e2cad5c714c6b2b8d9a81c774a',
pubkey: '53ce12f561b8ecf9e20ae19acb0201bdc661d9e36801b47a642d9f8fdb01a245',
sig: '72bb0acfe6174a51ab176b3bf178ebcaf648e4427fb5b5500341af1603be420509d833e56e4e4315b7f6a30f6223ea85475f0f2946dbad23dc6cf95959ce9646',
tags: [
['t', 'sp_4c43bd1d'],
['nonce', '3559b6bd', '8']
]
}
describe('shouldDropEventOnIngest', () => {
it('drops drift.gits.net kind-1 spam', () => {
expect(shouldDropEventOnIngest(DRIFT_GITS_SPAM)).toBe(true)
})
it('allows drift.gits.net spam on explicit note lookup', () => {
expect(
shouldDropEventOnIngest(DRIFT_GITS_SPAM, {
explicitNoteLookupHexId: DRIFT_GITS_SPAM.id
})
).toBe(false)
})
it('does not drop normal kind-1 text', () => {
expect(
shouldDropEventOnIngest({
...DRIFT_GITS_SPAM,
content: 'Hello nostr',
tags: []
})
).toBe(false)
})
})

29
src/lib/event-ingest-filter.ts

@ -41,9 +41,26 @@ function isKactiBroadcastSpamKind1(event: Pick<NEvent, 'kind' | 'content'>): boo @@ -41,9 +41,26 @@ function isKactiBroadcastSpamKind1(event: Pick<NEvent, 'kind' | 'content'>): boo
return c.startsWith('[broadcast:[#')
}
/**
* drift.gits.net kind-1 payloads (`sp_<id>.….drift.gits.net` + `t` tag) relay index noise, not discussion text.
*/
function isDriftGitsNetSpamKind1(
event: Pick<NEvent, 'kind' | 'content' | 'tags'>
): boolean {
if (event.kind !== kinds.ShortTextNote) return false
const c = typeof event.content === 'string' ? event.content.trim() : ''
if (/\.drift\.gits\.net$/i.test(c) || /^sp_[0-9a-f]+\./i.test(c)) return true
for (const tag of event.tags) {
if (tag[0] === 't' && typeof tag[1] === 'string' && /^sp_[0-9a-f]+$/i.test(tag[1].trim())) {
return true
}
}
return false
}
export type ShouldDropEventOnIngestOptions = {
/**
* When set to the same 64-char hex as {@link NEvent.id} (lowercase), {@link isKactiBroadcastSpamKind1} does not apply
* When set to the same 64-char hex as {@link NEvent.id} (lowercase), kind-1 ingest spam filters do not apply
* so `fetchEvent` / direct note views can still show the payload.
*/
explicitNoteLookupHexId?: string
@ -61,7 +78,8 @@ const DEPRECATED_NIP71_SHORT_VIDEO_ADDRESSABLE_KIND = 34236 @@ -61,7 +78,8 @@ const DEPRECATED_NIP71_SHORT_VIDEO_ADDRESSABLE_KIND = 34236
/**
* Single gate for subscribe/cache/IDB read paths: drop kind-1 JSON-object spam, Kacti broadcast spam,
* and malformed relay reviews. Optional {@link ShouldDropEventOnIngestOptions} relaxes Kacti drops for explicit id fetch.
* drift.gits.net spam, and malformed relay reviews. Optional {@link ShouldDropEventOnIngestOptions} relaxes
* kind-1 spam drops for explicit id fetch.
*/
export function shouldDropEventOnIngest(
event: NEvent,
@ -70,9 +88,12 @@ export function shouldDropEventOnIngest( @@ -70,9 +88,12 @@ export function shouldDropEventOnIngest(
if (event.kind === DEPRECATED_NIP71_SHORT_VIDEO_ADDRESSABLE_KIND) return true
if (isIncompleteRelayReviewIngest(event)) return true
if (isStringifiedJsonObjectContentNostrEvent(event)) return true
const relaxKind1Spam = explicitLookupMatchesEvent(event.id, options?.explicitNoteLookupHexId)
if (isKactiBroadcastSpamKind1(event)) {
if (explicitLookupMatchesEvent(event.id, options?.explicitNoteLookupHexId)) return false
return true
if (!relaxKind1Spam) return true
}
if (isDriftGitsNetSpamKind1(event)) {
if (!relaxKind1Spam) return true
}
return false
}

51
src/lib/nip05-affiliation.ts

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
import { NIP05_AFFILIATION_BY_DOMAIN, type TNip05AffiliationDomain } from '@/constants'
import { splitNip05Identifier } from '@/lib/nip05'
export function normalizeNip05AffiliationDomain(domain: string): string {
return domain.trim().toLowerCase().replace(/\.$/, '')
}
export function affiliationForNip05Domain(domain: string): TNip05AffiliationDomain | undefined {
return NIP05_AFFILIATION_BY_DOMAIN.get(normalizeNip05AffiliationDomain(domain))
}
/** Unique NIP-05 identifiers from kind-0 primary + list fields. */
export function collectProfileNip05Identifiers(
nip05?: string,
nip05List?: string[]
): string[] {
const seen = new Set<string>()
const out: string[] = []
const add = (raw?: string) => {
const id = raw?.trim()
if (!id || seen.has(id)) return
seen.add(id)
out.push(id)
}
add(nip05)
for (const entry of nip05List ?? []) {
add(entry)
}
return out
}
/**
* NIP-05 rows on the profile whose domain is in {@link NIP05_AFFILIATION_DOMAINS}.
* One row per identifier (verification runs separately).
*/
export function affiliationNip05CandidatesFromProfile(
nip05?: string,
nip05List?: string[]
): { nip05: string; affiliation: TNip05AffiliationDomain }[] {
const out: { nip05: string; affiliation: TNip05AffiliationDomain }[] = []
const domainsSeen = new Set<string>()
for (const id of collectProfileNip05Identifiers(nip05, nip05List)) {
const parts = splitNip05Identifier(id)
if (!parts) continue
const affiliation = affiliationForNip05Domain(parts.domain)
if (!affiliation || domainsSeen.has(affiliation.domain)) continue
domainsSeen.add(affiliation.domain)
out.push({ nip05: id, affiliation })
}
return out
}

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

@ -31,7 +31,8 @@ const THEFOREST_WELL_KNOWN = { @@ -31,7 +31,8 @@ const THEFOREST_WELL_KNOWN = {
describe('parseNip05NamePubkeysFromWellKnownJson', () => {
it('parses theforest.nostr1.com well-known names', () => {
const rows = parseNip05NamePubkeysFromWellKnownJson(THEFOREST_WELL_KNOWN)
expect(rows).toHaveLength(15)
expect(rows).toHaveLength(14)
expect(new Set(rows.map((r) => r.pubkey)).size).toBe(14)
expect(rows.find((r) => r.name === 'laeserin')?.pubkey).toBe(
'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319'
)
@ -71,6 +72,19 @@ describe('parseNip05NamePubkeysFromWellKnownJson', () => { @@ -71,6 +72,19 @@ describe('parseNip05NamePubkeysFromWellKnownJson', () => {
expect(rows.find((r) => r.name === 'laeserin')?.pubkey).toBe(laeserinHex)
})
it('dedupes multiple names for the same pubkey', () => {
const hex = '6da819f91d69cbe591c08b31f555c6d0ab9905197eb515856e339049c018c1af'
const rows = parseNip05NamePubkeysFromWellKnownJson({
names: {
'137': hex,
'430': hex,
laeserin: 'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319'
}
})
expect(rows).toHaveLength(2)
expect(rows.filter((r) => r.pubkey === hex)).toHaveLength(1)
})
it('partial name-filtered documents omit other users', () => {
const partial = {
names: {

23
src/lib/nip05.ts

@ -447,22 +447,33 @@ export async function fetchPubkeysFromDomain(domain: string): Promise<string[]> @@ -447,22 +447,33 @@ export async function fetchPubkeysFromDomain(domain: string): Promise<string[]>
return entries.map((e) => e.pubkey)
}
/** Prefer human NIP-05 local parts over `_`, hex keys, or npub labels when one pubkey appears twice. */
function nip05DomainListNameScore(name: string): number {
if (name === '_') return 0
if (/^[0-9a-f]{64}$/i.test(name) || name.startsWith('npub1')) return 1
return 2
}
export function parseNip05NamePubkeysFromWellKnownJson(
json: Record<string, unknown>
): Array<{ name: string; pubkey: string }> {
const normalized = normalizeWellKnownDocument(json)
if (!normalized) return []
const names = normalized.names as Record<string, unknown>
const out: Array<{ name: string; pubkey: string }> = []
const seen = new Set<string>()
const byPubkey = new Map<string, { name: string; pubkey: 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)
const pk = entry.pubkey.toLowerCase()
const prev = byPubkey.get(pk)
if (
!prev ||
nip05DomainListNameScore(entry.name) > nip05DomainListNameScore(prev.name)
) {
byPubkey.set(pk, { name: entry.name, pubkey: pk })
}
}
const out = [...byPubkey.values()]
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}

Loading…
Cancel
Save