diff --git a/src/components/Nip05AffiliationBadges/index.tsx b/src/components/Nip05AffiliationBadges/index.tsx
new file mode 100644
index 00000000..34eed898
--- /dev/null
+++ b/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 (
+ e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ >
+ {affiliations.map((aff) => {
+ const label = aff.label ?? aff.domain
+ return (
+
+ {aff.emoji}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/Nip05DomainPanel/ProfileListByNip05Domain.tsx b/src/components/Nip05DomainPanel/ProfileListByNip05Domain.tsx
index 6ffce273..5290930d 100644
--- a/src/components/Nip05DomainPanel/ProfileListByNip05Domain.tsx
+++ b/src/components/Nip05DomainPanel/ProfileListByNip05Domain.tsx
@@ -66,7 +66,7 @@ export default function ProfileListByNip05Domain({ domain }: { domain: string })
{visible.map(({ name, pubkey }) => (
{name && name !== '_' ? (
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx
index ffbe7b3b..4270c075 100644
--- a/src/components/Note/index.tsx
+++ b/src/components/Note/index.tsx
@@ -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({
className={`max-w-[min(12rem,40vw)] shrink font-semibold truncate ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
-
{t(notificationReactionSummaryKey(reactionDisplay))}
@@ -718,7 +716,6 @@ export default function Note({
>
{t('Imwald synthetic event')}
-
>
@@ -730,45 +727,18 @@ export default function Note({
maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={deferAuthorAvatar}
/>
- {showFull ? (
-
- ) : (
-
-
-
-
-
-
-
-
-
- )}
+
>
)}
diff --git a/src/components/NoteAuthorMetaLine/index.tsx b/src/components/NoteAuthorMetaLine/index.tsx
new file mode 100644
index 00000000..439bd8af
--- /dev/null
+++ b/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 (
+
+
+
+
+
+ {powEvent ? : null}
+
+
+ )
+}
diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx
index 3facc72c..520500eb 100644
--- a/src/components/NoteOptions/useMenuActions.tsx
+++ b/src/components/NoteOptions/useMenuActions.tsx
@@ -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'
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({
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({
!isDiscussion &&
!isReplyToDiscussion
+ const advancedAuthorMetaRows: SubMenuAction[] = []
+ if (getUsingClient(event)) {
+ advancedAuthorMetaRows.push({
+ label: (
+
+ {t('Posted via')}
+
+
+ ),
+ onClick: () => {},
+ className: 'cursor-default focus:bg-transparent data-[highlighted]:bg-transparent'
+ })
+ }
+ advancedAuthorMetaRows.push({
+ label: (
+ e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ >
+ NIP-05
+
+
+ ),
+ 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({
}
}
+ 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({
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({
}
})
},
- separator: true
+ separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator
})
}
@@ -1432,7 +1532,10 @@ export function useMenuActions({
seenOnRelays,
push,
currentPrimaryPage,
- isReplyToDiscussion
+ isReplyToDiscussion,
+ threadWatch,
+ threadFollowed,
+ threadMuted
])
return menuActions
diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx
index 3078efe7..3bd85667 100644
--- a/src/components/NoteStats/index.tsx
+++ b/src/components/NoteStats/index.tsx
@@ -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({
return (
*]: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({
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[] = [
-
+
]
@@ -174,22 +169,12 @@ export default function NoteStats({
if (!isRssArticleRoot) {
barItems.push(
-
+
)
}
- if (!isRssArticleRoot && showThreadWatchButtons) {
- barItems.push(
-
-
-
-
-
- )
- }
-
return (
([])
+ const [profilesByPubkey, setProfilesByPubkey] = useState
diff --git a/src/components/Username/index.tsx b/src/components/Username/index.tsx
index 629d1e0c..0413a063 100644
--- a/src/components/Username/index.tsx
+++ b/src/components/Username/index.tsx
@@ -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
@@ -60,7 +68,7 @@ export default function Username({
if (profile) {
const { username, pubkey: profilePubkey } = profile
return (
- = new Map(
+ NIP05_AFFILIATION_DOMAINS.map((entry) => [entry.domain.toLowerCase(), entry])
+)
+
/**
* Hex-id / replaceable-coordinate note lookup ({@link EventService.tryHarderToFetchEvent}, big-relays dataloader).
*/
diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx
index b93d19ca..98ea519e 100644
--- a/src/hooks/useFetchProfile.tsx
+++ b/src/hooks/useFetchProfile.tsx
@@ -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).
diff --git a/src/hooks/useThreadNotificationMenuState.ts b/src/hooks/useThreadNotificationMenuState.ts
new file mode 100644
index 00000000..ed3d677e
--- /dev/null
+++ b/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 }
+}
diff --git a/src/hooks/useVerifiedNip05Affiliations.ts b/src/hooks/useVerifiedNip05Affiliations.ts
new file mode 100644
index 00000000..2c9a2efa
--- /dev/null
+++ b/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([])
+
+ useEffect(() => {
+ if (!pubkey || candidates.length === 0) {
+ setVerified([])
+ return
+ }
+ let cancelled = false
+ void (async () => {
+ const confirmed = new Set()
+ 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
+}
diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts
index 4c87e117..1ba66982 100644
--- a/src/i18n/locales/cs.ts
+++ b/src/i18n/locales/cs.ts
@@ -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',
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 795043ba..37b9ea0d 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -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 {
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',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 0decc601..7089d626 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -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 {
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',
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index 0e31b3ea..216fef19 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -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',
diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts
index 00ca81a3..6f6d3860 100644
--- a/src/i18n/locales/fr.ts
+++ b/src/i18n/locales/fr.ts
@@ -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',
diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts
index defc81d5..32535ac1 100644
--- a/src/i18n/locales/nl.ts
+++ b/src/i18n/locales/nl.ts
@@ -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',
diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts
index 2f8b924c..5792ac70 100644
--- a/src/i18n/locales/pl.ts
+++ b/src/i18n/locales/pl.ts
@@ -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',
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index b874dfb3..b46dd49c 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -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',
diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts
index d2a177ef..c0c49c39 100644
--- a/src/i18n/locales/tr.ts
+++ b/src/i18n/locales/tr.ts
@@ -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',
diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts
index fb12ba54..3e1adab7 100644
--- a/src/i18n/locales/zh.ts
+++ b/src/i18n/locales/zh.ts
@@ -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',
diff --git a/src/lib/event-ingest-filter.test.ts b/src/lib/event-ingest-filter.test.ts
new file mode 100644
index 00000000..91d3db1f
--- /dev/null
+++ b/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)
+ })
+})
diff --git a/src/lib/event-ingest-filter.ts b/src/lib/event-ingest-filter.ts
index a1e7bde1..391497c3 100644
--- a/src/lib/event-ingest-filter.ts
+++ b/src/lib/event-ingest-filter.ts
@@ -41,9 +41,26 @@ function isKactiBroadcastSpamKind1(event: Pick): boo
return c.startsWith('[broadcast:[#')
}
+/**
+ * drift.gits.net kind-1 payloads (`sp_.….drift.gits.net` + `t` tag) — relay index noise, not discussion text.
+ */
+function isDriftGitsNetSpamKind1(
+ event: Pick
+): 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
/**
* 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(
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
}
diff --git a/src/lib/nip05-affiliation.ts b/src/lib/nip05-affiliation.ts
new file mode 100644
index 00000000..c4437f25
--- /dev/null
+++ b/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()
+ 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()
+ 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
+}
diff --git a/src/lib/nip05-well-known.test.ts b/src/lib/nip05-well-known.test.ts
index 74c817e5..1ed01ccd 100644
--- a/src/lib/nip05-well-known.test.ts
+++ b/src/lib/nip05-well-known.test.ts
@@ -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', () => {
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: {
diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts
index 58e4fa73..12656bf2 100644
--- a/src/lib/nip05.ts
+++ b/src/lib/nip05.ts
@@ -447,22 +447,33 @@ export async function fetchPubkeysFromDomain(domain: string): Promise
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
): Array<{ name: string; pubkey: string }> {
const normalized = normalizeWellKnownDocument(json)
if (!normalized) return []
const names = normalized.names as Record
- const out: Array<{ name: string; pubkey: string }> = []
- const seen = new Set()
+ const byPubkey = new Map()
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
}