28 changed files with 653 additions and 130 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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