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. 50
      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. 27
      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 @@
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 })
<div className="px-4 pt-2"> <div className="px-4 pt-2">
{visible.map(({ name, pubkey }) => ( {visible.map(({ name, pubkey }) => (
<div <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" className="flex min-w-0 items-center gap-2 border-b border-border/40 py-1 last:border-0"
> >
{name && name !== '_' ? ( {name && name !== '_' ? (

50
src/components/Note/index.tsx

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

38
src/components/NoteAuthorMetaLine/index.tsx

@ -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 @@
import { ExtendedKind, READ_ALOUD_KINDS } from '@/constants' import { ExtendedKind, READ_ALOUD_KINDS } from '@/constants'
import ClientTag from '@/components/ClientTag'
import Nip05 from '@/components/Nip05'
import { import {
getNoteBech32Id, getNoteBech32Id,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
getUsingClient,
isProtectedEvent, isProtectedEvent,
isReplaceableEvent, isReplaceableEvent,
getRootEventHexId getRootEventHexId
@ -47,6 +50,7 @@ import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useBookmarksOptional } from '@/providers/bookmarks-context' import { useBookmarksOptional } from '@/providers/bookmarks-context'
import { useThreadNotificationMenuState } from '@/hooks/useThreadNotificationMenuState'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
@ -168,6 +172,7 @@ export function useMenuActions({
checkLogin checkLogin
} = useNostr() } = useNostr()
const bookmarksContext = useBookmarksOptional() const bookmarksContext = useBookmarksOptional()
const { threadFollowed, threadMuted, threadWatch } = useThreadNotificationMenuState(event)
const { addBookmark, removeBookmark } = bookmarksContext ?? { const { addBookmark, removeBookmark } = bookmarksContext ?? {
addBookmark: async () => {}, addBookmark: async () => {},
removeBookmark: async () => false removeBookmark: async () => false
@ -1114,9 +1119,40 @@ export function useMenuActions({
!isDiscussion && !isDiscussion &&
!isReplyToDiscussion !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[] = [ const advancedSubMenu: SubMenuAction[] = [
...advancedAuthorMetaRows,
{ {
label: t('Copy event ID'), label: t('Copy event ID'),
separator: advancedAuthorMetaRows.length > 0,
onClick: () => { onClick: () => {
navigator.clipboard.writeText(getNoteBech32Id(event)) navigator.clipboard.writeText(getNoteBech32Id(event))
closeDrawer() closeDrawer()
@ -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) { if (pubkey && event.pubkey === pubkey) {
actions.push({ actions.push({
icon: Pin, icon: Pin,
@ -1346,7 +1446,7 @@ export function useMenuActions({
onClick: () => { onClick: () => {
handlePinNote() handlePinNote()
}, },
separator: true separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator
}) })
} else if (pubkey && event.pubkey !== pubkey && bookmarksContext) { } else if (pubkey && event.pubkey !== pubkey && bookmarksContext) {
actions.push({ actions.push({
@ -1374,7 +1474,7 @@ export function useMenuActions({
} }
}) })
}, },
separator: true separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator
}) })
} }
@ -1432,7 +1532,10 @@ export function useMenuActions({
seenOnRelays, seenOnRelays,
push, push,
currentPrimaryPage, currentPrimaryPage,
isReplyToDiscussion isReplyToDiscussion,
threadWatch,
threadFollowed,
threadMuted
]) ])
return menuActions return menuActions

28
src/components/NoteStats/index.tsx

@ -10,14 +10,12 @@ import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useRef, useState, type ReactNode } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import NotificationThreadWatchButtons from '../NotificationThreadWatchButtons'
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider'
import { LikeButtonWithStats } from './LikeButton' import { LikeButtonWithStats } from './LikeButton'
import { ReplyButtonWithStats } from './ReplyButton' import { ReplyButtonWithStats } from './ReplyButton'
import { RepostButtonWithStats } from './RepostButton' import { RepostButtonWithStats } from './RepostButton'
import { ZapButtonWithStats } from './ZapButton' 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({ function NoteStatsBarItem({
children, children,
className className
@ -28,7 +26,7 @@ function NoteStatsBarItem({
return ( return (
<div <div
className={cn( 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 className
)} )}
> >
@ -140,15 +138,12 @@ export default function NoteStats({
statsFetchRelayScopeKey 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. */ /** Kind 11 / 1111 under a discussion: up+down votes need more width than a single like button. */
const isDiscussionBar = isDiscussion || isReplyToDiscussion const isDiscussionBar = isDiscussion || isReplyToDiscussion
const compactBarItem = isDiscussionBar ? 'shrink-0 flex-none basis-auto' : undefined const voteBarItem = isDiscussionBar ? 'min-w-[6.75rem] sm:min-w-[7.25rem]' : undefined
const voteBarItem = isDiscussionBar ? 'min-w-[6.75rem] flex-[2] basis-28 sm:min-w-[7.25rem]' : undefined
const barItems: ReactNode[] = [ const barItems: ReactNode[] = [
<NoteStatsBarItem key="reply" className={compactBarItem}> <NoteStatsBarItem key="reply">
<ReplyButtonWithStats event={event} noteStats={noteStats} /> <ReplyButtonWithStats event={event} noteStats={noteStats} />
</NoteStatsBarItem> </NoteStatsBarItem>
] ]
@ -174,22 +169,12 @@ export default function NoteStats({
if (!isRssArticleRoot) { if (!isRssArticleRoot) {
barItems.push( barItems.push(
<NoteStatsBarItem key="tip" className={compactBarItem}> <NoteStatsBarItem key="tip">
<ZapButtonWithStats event={event} noteStats={noteStats} /> <ZapButtonWithStats event={event} noteStats={noteStats} />
</NoteStatsBarItem> </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 ( return (
<div <div
ref={containerRef} ref={containerRef}
@ -199,7 +184,8 @@ export default function NoteStats({
> >
<div <div
className={cn( 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' : '', loading ? 'animate-pulse' : '',
classNames?.buttonBar classNames?.buttonBar
)} )}

87
src/components/ProfileList/index.tsx

@ -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 { useEffect, useMemo, useRef, useState } from 'react'
import UserItem from '../UserItem' import UserItem from '../UserItem'
const PROFILE_CHUNK = 80
export default function ProfileList({ pubkeys }: { pubkeys: string[] }) { export default function ProfileList({ pubkeys }: { pubkeys: string[] }) {
const [visiblePubkeys, setVisiblePubkeys] = useState<string[]>([]) const [visiblePubkeys, setVisiblePubkeys] = useState<string[]>([])
const [profilesByPubkey, setProfilesByPubkey] = useState<Map<string, TProfile>>(() => new Map())
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const loadedRef = useRef<Set<string>>(new Set())
const batchGenRef = useRef(0)
const pubkeysKey = useMemo(() => pubkeys.join('\u0001'), [pubkeys]) const pubkeysKey = useMemo(() => pubkeys.join('\u0001'), [pubkeys])
useEffect(() => { useEffect(() => {
@ -35,11 +43,84 @@ export default function ProfileList({ pubkeys }: { pubkeys: string[] }) {
} }
}, [visiblePubkeys, pubkeysKey, pubkeys]) }, [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 ( return (
<div className="px-4 pt-2"> <div className="px-4 pt-2">
{visiblePubkeys.map((pubkey, index) => ( {visiblePubkeys.map((pubkey, index) => {
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} /> 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} />} {pubkeys.length > visiblePubkeys.length && <div ref={bottomRef} />}
</div> </div>
) )

27
src/components/ReplyNote/index.tsx

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

10
src/components/Username/index.tsx

@ -33,7 +33,15 @@ export default function Username({
const { profile: fetchedProfile, isFetching } = useFetchProfile(userId) const { profile: fetchedProfile, isFetching } = useFetchProfile(userId)
const profile = useMemo(() => { const profile = useMemo(() => {
const idPk = userId ? userIdToPubkey(userId) : '' 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 ?? prefetchedProfile
} }
return fetchedProfile return fetchedProfile

25
src/constants.ts

@ -276,6 +276,31 @@ export const PROFILE_BATCH_POST_COOLDOWN_MS = 90_000
*/ */
export const PROFILE_SECONDARY_PANEL_DEFER_MS = 120_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). * 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) {
setPubkey(extractedPubkey) setPubkey(extractedPubkey)
setIsFetching(false) setIsFetching(false)
setError(null) 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). // Skip only when this pubkey already has an in-flight fetch (global dedupe + local flag).

57
src/hooks/useThreadNotificationMenuState.ts

@ -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 @@
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 {
Quotes: 'Quotes', Quotes: 'Quotes',
'Lightning Invoice': 'Lightning Invoice', 'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed', 'Bookmark failed': 'Bookmark failed',
'Follow this': 'Follow this', 'Follow this': 'Follow this thread',
'Mute this': 'Mute this', 'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications', 'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications', 'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications', 'Unfollow thread notifications': 'Unfollow thread notifications',

6
src/i18n/locales/de.ts

@ -476,6 +476,8 @@ export default {
'Add an Account': 'Konto hinzufügen', 'Add an Account': 'Konto hinzufügen',
'More options': 'Mehr Optionen', 'More options': 'Mehr Optionen',
'Add client tag': 'Client-Tag hinzufügen', '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', '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?', 'Are you sure you want to logout?': 'Bist du sicher, dass du dich abmelden möchtest?',
'relay sets': 'Relay-Sets', 'relay sets': 'Relay-Sets',
@ -931,8 +933,8 @@ export default {
Quotes: 'Zitate', Quotes: 'Zitate',
'Lightning Invoice': 'Lightning-Rechnung', 'Lightning Invoice': 'Lightning-Rechnung',
'Bookmark failed': 'Bookmark fehlgeschlagen', 'Bookmark failed': 'Bookmark fehlgeschlagen',
'Follow this': 'Follow this', 'Follow this': 'Diesem Thread folgen',
'Mute this': 'Mute this', 'Mute this': 'Diesen Thread stummschalten',
'Following thread for notifications': 'Following thread for notifications', 'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications', 'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications', 'Unfollow thread notifications': 'Unfollow thread notifications',

6
src/i18n/locales/en.ts

@ -473,6 +473,8 @@ export default {
'Add an Account': 'Add an Account', 'Add an Account': 'Add an Account',
'More options': 'More options', 'More options': 'More options',
'Add client tag': 'Add client tag', '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', '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?', 'Are you sure you want to logout?': 'Are you sure you want to logout?',
'relay sets': 'relay sets', 'relay sets': 'relay sets',
@ -925,8 +927,8 @@ export default {
Quotes: 'Quotes', Quotes: 'Quotes',
'Lightning Invoice': 'Lightning Invoice', 'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed', 'Bookmark failed': 'Bookmark failed',
'Follow this': 'Follow this', 'Follow this': 'Follow this thread',
'Mute this': 'Mute this', 'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications', 'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications', 'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications', 'Unfollow thread notifications': 'Unfollow thread notifications',

4
src/i18n/locales/es.ts

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

4
src/i18n/locales/fr.ts

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

4
src/i18n/locales/nl.ts

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

4
src/i18n/locales/pl.ts

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

4
src/i18n/locales/ru.ts

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

4
src/i18n/locales/tr.ts

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

4
src/i18n/locales/zh.ts

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

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

@ -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
return c.startsWith('[broadcast:[#') 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 = { 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. * so `fetchEvent` / direct note views can still show the payload.
*/ */
explicitNoteLookupHexId?: string explicitNoteLookupHexId?: string
@ -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, * 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( export function shouldDropEventOnIngest(
event: NEvent, event: NEvent,
@ -70,9 +88,12 @@ export function shouldDropEventOnIngest(
if (event.kind === DEPRECATED_NIP71_SHORT_VIDEO_ADDRESSABLE_KIND) return true if (event.kind === DEPRECATED_NIP71_SHORT_VIDEO_ADDRESSABLE_KIND) return true
if (isIncompleteRelayReviewIngest(event)) return true if (isIncompleteRelayReviewIngest(event)) return true
if (isStringifiedJsonObjectContentNostrEvent(event)) return true if (isStringifiedJsonObjectContentNostrEvent(event)) return true
const relaxKind1Spam = explicitLookupMatchesEvent(event.id, options?.explicitNoteLookupHexId)
if (isKactiBroadcastSpamKind1(event)) { if (isKactiBroadcastSpamKind1(event)) {
if (explicitLookupMatchesEvent(event.id, options?.explicitNoteLookupHexId)) return false if (!relaxKind1Spam) return true
return true }
if (isDriftGitsNetSpamKind1(event)) {
if (!relaxKind1Spam) return true
} }
return false return false
} }

51
src/lib/nip05-affiliation.ts

@ -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 = {
describe('parseNip05NamePubkeysFromWellKnownJson', () => { describe('parseNip05NamePubkeysFromWellKnownJson', () => {
it('parses theforest.nostr1.com well-known names', () => { it('parses theforest.nostr1.com well-known names', () => {
const rows = parseNip05NamePubkeysFromWellKnownJson(THEFOREST_WELL_KNOWN) 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( expect(rows.find((r) => r.name === 'laeserin')?.pubkey).toBe(
'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319' 'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319'
) )
@ -71,6 +72,19 @@ describe('parseNip05NamePubkeysFromWellKnownJson', () => {
expect(rows.find((r) => r.name === 'laeserin')?.pubkey).toBe(laeserinHex) 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', () => { it('partial name-filtered documents omit other users', () => {
const partial = { const partial = {
names: { names: {

23
src/lib/nip05.ts

@ -447,22 +447,33 @@ export async function fetchPubkeysFromDomain(domain: string): Promise<string[]>
return entries.map((e) => e.pubkey) 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( export function parseNip05NamePubkeysFromWellKnownJson(
json: Record<string, unknown> json: Record<string, unknown>
): Array<{ name: string; pubkey: string }> { ): Array<{ name: string; pubkey: string }> {
const normalized = normalizeWellKnownDocument(json) const normalized = normalizeWellKnownDocument(json)
if (!normalized) return [] if (!normalized) return []
const names = normalized.names as Record<string, unknown> const names = normalized.names as Record<string, unknown>
const out: Array<{ name: string; pubkey: string }> = [] const byPubkey = new Map<string, { name: string; pubkey: string }>()
const seen = new Set<string>()
for (const [key, v] of Object.entries(names)) { for (const [key, v] of Object.entries(names)) {
const entry = parseNip05NamePubkeyEntry(key, v) const entry = parseNip05NamePubkeyEntry(key, v)
if (!entry || !isValidPubkey(entry.pubkey)) continue if (!entry || !isValidPubkey(entry.pubkey)) continue
const dedupe = `${entry.name}:${entry.pubkey}` const pk = entry.pubkey.toLowerCase()
if (seen.has(dedupe)) continue const prev = byPubkey.get(pk)
seen.add(dedupe) if (
out.push(entry) !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)) out.sort((a, b) => a.name.localeCompare(b.name))
return out return out
} }

Loading…
Cancel
Save