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>(() => new Map()) const bottomRef = useRef(null) + const loadedRef = useRef>(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[] }) { } }, [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 (
- {visiblePubkeys.map((pubkey, index) => ( - - ))} + {visiblePubkeys.map((pubkey, index) => { + const pkNorm = pubkey.length === 64 ? pubkey.toLowerCase() : pubkey + const prefetchedProfile = profilesByPubkey.get(pkNorm) + return ( + + ) + })} {pubkeys.length > visiblePubkeys.length &&
}
) diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 8cd4d025..a764be3c 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -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({ maxFileSizeKb={2048} deferRemoteAvatar={false} /> -
-
- - -
-
- - - -
-
+
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 }