28 changed files with 653 additions and 130 deletions
@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
|
||||
import { useFetchProfile } from '@/hooks' |
||||
import { useVerifiedNip05Affiliations } from '@/hooks/useVerifiedNip05Affiliations' |
||||
import { userIdToPubkey } from '@/lib/pubkey' |
||||
import { cn } from '@/lib/utils' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function Nip05AffiliationBadges({ |
||||
userId, |
||||
pubkey: pubkeyProp, |
||||
nip05: nip05Prop, |
||||
nip05List: nip05ListProp, |
||||
className |
||||
}: { |
||||
/** Hex or npub — loads kind 0 for NIP-05 when `nip05` / `nip05List` omitted. */ |
||||
userId?: string |
||||
pubkey?: string |
||||
nip05?: string |
||||
nip05List?: string[] |
||||
className?: string |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const pubkey = pubkeyProp ?? (userId ? userIdToPubkey(userId) : '') |
||||
const { profile } = useFetchProfile( |
||||
nip05Prop === undefined && nip05ListProp === undefined && pubkey ? pubkey : undefined |
||||
) |
||||
const nip05 = nip05Prop ?? profile?.nip05 |
||||
const nip05List = nip05ListProp ?? profile?.nip05List |
||||
const affiliations = useVerifiedNip05Affiliations(pubkey, nip05, nip05List) |
||||
|
||||
if (affiliations.length === 0) return null |
||||
|
||||
return ( |
||||
<span |
||||
className={cn('inline-flex shrink-0 items-center gap-0.5', className)} |
||||
onClick={(e) => e.stopPropagation()} |
||||
onPointerDown={(e) => e.stopPropagation()} |
||||
> |
||||
{affiliations.map((aff) => { |
||||
const label = aff.label ?? aff.domain |
||||
return ( |
||||
<span |
||||
key={aff.domain} |
||||
role="img" |
||||
aria-label={t('Verified NIP-05 affiliation', { domain: label })} |
||||
title={t('Verified NIP-05 affiliation', { domain: label })} |
||||
className="inline-flex size-[1.05em] items-center justify-center text-sm leading-none select-none grayscale contrast-125 opacity-90" |
||||
> |
||||
{aff.emoji} |
||||
</span> |
||||
) |
||||
})} |
||||
</span> |
||||
) |
||||
} |
||||
@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
|
||||
import Nip05AffiliationBadges from '@/components/Nip05AffiliationBadges' |
||||
import { FormattedTimestamp } from '@/components/FormattedTimestamp' |
||||
import EventPowLabel from '@/components/EventPowLabel' |
||||
import Username from '@/components/Username' |
||||
import { cn } from '@/lib/utils' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
/** Username, relative time, verified NIP-05 affiliation badges, optional PoW — one header row. */ |
||||
export default function NoteAuthorMetaLine({ |
||||
userId, |
||||
timestamp, |
||||
powEvent, |
||||
usernameClassName, |
||||
skeletonClassName, |
||||
timestampShort = false |
||||
}: { |
||||
userId: string |
||||
timestamp: number |
||||
powEvent?: Event |
||||
usernameClassName?: string |
||||
skeletonClassName?: string |
||||
timestampShort?: boolean |
||||
}) { |
||||
return ( |
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-0.5 overflow-hidden"> |
||||
<Username |
||||
userId={userId} |
||||
className={cn('shrink font-semibold truncate', usernameClassName)} |
||||
skeletonClassName={skeletonClassName} |
||||
/> |
||||
<span className="inline-flex min-w-0 shrink-0 items-center gap-x-1.5 text-sm text-muted-foreground"> |
||||
<FormattedTimestamp timestamp={timestamp} className="shrink-0" short={timestampShort} /> |
||||
<Nip05AffiliationBadges userId={userId} /> |
||||
{powEvent ? <EventPowLabel event={powEvent} /> : null} |
||||
</span> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { |
||||
eventHasExactNotificationThreadWatchRef, |
||||
parseThreadWatchListRefs |
||||
} from '@/lib/notification-thread-watch' |
||||
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import type { Event } from 'nostr-tools' |
||||
import { useCallback, useEffect, useState } from 'react' |
||||
|
||||
/** Local kind 19130 / 19132 lists — thread follow/mute menu state (not the open note’s kind). */ |
||||
export function useThreadNotificationMenuState(event: Event) { |
||||
const { pubkey } = useNostr() |
||||
const threadWatch = useNotificationThreadWatchOptional() |
||||
const [idbFollowed, setIdbFollowed] = useState(false) |
||||
const [idbMuted, setIdbMuted] = useState(false) |
||||
|
||||
const refreshFromIdb = useCallback(async () => { |
||||
if (!pubkey) { |
||||
setIdbFollowed(false) |
||||
setIdbMuted(false) |
||||
return |
||||
} |
||||
const pk = pubkey.trim().toLowerCase() |
||||
try { |
||||
const [followEv, muteEv] = await Promise.all([ |
||||
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST), |
||||
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST) |
||||
]) |
||||
const followRefs = parseThreadWatchListRefs(followEv ?? undefined) |
||||
const muteRefs = parseThreadWatchListRefs(muteEv ?? undefined) |
||||
setIdbFollowed(eventHasExactNotificationThreadWatchRef(event, followRefs)) |
||||
setIdbMuted(eventHasExactNotificationThreadWatchRef(event, muteRefs)) |
||||
} catch { |
||||
setIdbFollowed(false) |
||||
setIdbMuted(false) |
||||
} |
||||
}, [pubkey, event.id, event.kind, event.created_at]) |
||||
|
||||
useEffect(() => { |
||||
void refreshFromIdb() |
||||
}, [ |
||||
refreshFromIdb, |
||||
threadWatch?.eventsIFollowListEvent?.id, |
||||
threadWatch?.eventsIMutedListEvent?.id |
||||
]) |
||||
|
||||
const threadFollowed = threadWatch |
||||
? threadWatch.isFollowedForNotifications(event) |
||||
: idbFollowed |
||||
const threadMuted = threadWatch |
||||
? threadWatch.isMutedForNotifications(event) |
||||
: idbMuted |
||||
|
||||
return { threadFollowed, threadMuted, threadWatch } |
||||
} |
||||
@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
|
||||
import { NIP05_AFFILIATION_DOMAINS, type TNip05AffiliationDomain } from '@/constants' |
||||
import { affiliationNip05CandidatesFromProfile } from '@/lib/nip05-affiliation' |
||||
import { verifyNip05 } from '@/lib/nip05' |
||||
import { useEffect, useMemo, useState } from 'react' |
||||
|
||||
export function useVerifiedNip05Affiliations( |
||||
pubkey: string | undefined, |
||||
nip05?: string, |
||||
nip05List?: string[] |
||||
): readonly TNip05AffiliationDomain[] { |
||||
const candidates = useMemo( |
||||
() => affiliationNip05CandidatesFromProfile(nip05, nip05List), |
||||
[nip05, nip05List] |
||||
) |
||||
const candidatesKey = useMemo( |
||||
() => candidates.map((c) => c.nip05).join('\u0001'), |
||||
[candidates] |
||||
) |
||||
const [verified, setVerified] = useState<readonly TNip05AffiliationDomain[]>([]) |
||||
|
||||
useEffect(() => { |
||||
if (!pubkey || candidates.length === 0) { |
||||
setVerified([]) |
||||
return |
||||
} |
||||
let cancelled = false |
||||
void (async () => { |
||||
const confirmed = new Set<string>() |
||||
await Promise.all( |
||||
candidates.map(async ({ nip05: nip05Id, affiliation }) => { |
||||
const result = await verifyNip05(nip05Id, pubkey) |
||||
if ( |
||||
result.isVerified && |
||||
result.nip05Domain.toLowerCase() === affiliation.domain |
||||
) { |
||||
confirmed.add(affiliation.domain) |
||||
} |
||||
}) |
||||
) |
||||
if (cancelled) return |
||||
setVerified( |
||||
NIP05_AFFILIATION_DOMAINS.filter((entry) => confirmed.has(entry.domain)) |
||||
) |
||||
})() |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [pubkey, candidatesKey]) |
||||
|
||||
return verified |
||||
} |
||||
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
const DRIFT_GITS_SPAM: Event = { |
||||
kind: 1, |
||||
content: |
||||
'sp_4c43bd1d.949ac75f.06.OHCFKDGO2J6TV4KYHAB2JBLIMXHR6RQWVYAGRVBPBUKCH6CPR7JJU3PMG4SBCQA.drift.gits.net', |
||||
created_at: 1780215168, |
||||
id: '00f077ecb154545e5a5ae98b1fe28db5e30661e2cad5c714c6b2b8d9a81c774a', |
||||
pubkey: '53ce12f561b8ecf9e20ae19acb0201bdc661d9e36801b47a642d9f8fdb01a245', |
||||
sig: '72bb0acfe6174a51ab176b3bf178ebcaf648e4427fb5b5500341af1603be420509d833e56e4e4315b7f6a30f6223ea85475f0f2946dbad23dc6cf95959ce9646', |
||||
tags: [ |
||||
['t', 'sp_4c43bd1d'], |
||||
['nonce', '3559b6bd', '8'] |
||||
] |
||||
} |
||||
|
||||
describe('shouldDropEventOnIngest', () => { |
||||
it('drops drift.gits.net kind-1 spam', () => { |
||||
expect(shouldDropEventOnIngest(DRIFT_GITS_SPAM)).toBe(true) |
||||
}) |
||||
|
||||
it('allows drift.gits.net spam on explicit note lookup', () => { |
||||
expect( |
||||
shouldDropEventOnIngest(DRIFT_GITS_SPAM, { |
||||
explicitNoteLookupHexId: DRIFT_GITS_SPAM.id |
||||
}) |
||||
).toBe(false) |
||||
}) |
||||
|
||||
it('does not drop normal kind-1 text', () => { |
||||
expect( |
||||
shouldDropEventOnIngest({ |
||||
...DRIFT_GITS_SPAM, |
||||
content: 'Hello nostr', |
||||
tags: [] |
||||
}) |
||||
).toBe(false) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
|
||||
import { NIP05_AFFILIATION_BY_DOMAIN, type TNip05AffiliationDomain } from '@/constants' |
||||
import { splitNip05Identifier } from '@/lib/nip05' |
||||
|
||||
export function normalizeNip05AffiliationDomain(domain: string): string { |
||||
return domain.trim().toLowerCase().replace(/\.$/, '') |
||||
} |
||||
|
||||
export function affiliationForNip05Domain(domain: string): TNip05AffiliationDomain | undefined { |
||||
return NIP05_AFFILIATION_BY_DOMAIN.get(normalizeNip05AffiliationDomain(domain)) |
||||
} |
||||
|
||||
/** Unique NIP-05 identifiers from kind-0 primary + list fields. */ |
||||
export function collectProfileNip05Identifiers( |
||||
nip05?: string, |
||||
nip05List?: string[] |
||||
): string[] { |
||||
const seen = new Set<string>() |
||||
const out: string[] = [] |
||||
const add = (raw?: string) => { |
||||
const id = raw?.trim() |
||||
if (!id || seen.has(id)) return |
||||
seen.add(id) |
||||
out.push(id) |
||||
} |
||||
add(nip05) |
||||
for (const entry of nip05List ?? []) { |
||||
add(entry) |
||||
} |
||||
return out |
||||
} |
||||
|
||||
/** |
||||
* NIP-05 rows on the profile whose domain is in {@link NIP05_AFFILIATION_DOMAINS}. |
||||
* One row per identifier (verification runs separately). |
||||
*/ |
||||
export function affiliationNip05CandidatesFromProfile( |
||||
nip05?: string, |
||||
nip05List?: string[] |
||||
): { nip05: string; affiliation: TNip05AffiliationDomain }[] { |
||||
const out: { nip05: string; affiliation: TNip05AffiliationDomain }[] = [] |
||||
const domainsSeen = new Set<string>() |
||||
for (const id of collectProfileNip05Identifiers(nip05, nip05List)) { |
||||
const parts = splitNip05Identifier(id) |
||||
if (!parts) continue |
||||
const affiliation = affiliationForNip05Domain(parts.domain) |
||||
if (!affiliation || domainsSeen.has(affiliation.domain)) continue |
||||
domainsSeen.add(affiliation.domain) |
||||
out.push({ nip05: id, affiliation }) |
||||
} |
||||
return out |
||||
} |
||||
Loading…
Reference in new issue