19 changed files with 474 additions and 176 deletions
@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { |
||||
collectPaymentAttestationsFromSession, |
||||
mergeAttestedPaymentIdSets |
||||
} from '@/lib/payment-attestation-cache' |
||||
import { buildGlobalAttestedSuperchatIdSet } from '@/lib/superchat' |
||||
import client from '@/services/client.service' |
||||
import type { Event as NostrEvent } from 'nostr-tools' |
||||
import { useCallback, useEffect, useMemo, useState } from 'react' |
||||
|
||||
function idsFromAttestations(attestations: NostrEvent[]): Set<string> { |
||||
return buildGlobalAttestedSuperchatIdSet(attestations) |
||||
} |
||||
|
||||
/** Attested superchat target ids (9735 / 9740 / 9736 / 1814) for feed filtering. */ |
||||
export function useFeedAttestedSuperchatIds(relayUrls: string[]): Set<string> { |
||||
const [attestedIds, setAttestedIds] = useState<Set<string>>(() => |
||||
idsFromAttestations(collectPaymentAttestationsFromSession()) |
||||
) |
||||
|
||||
const mergeAttestations = useCallback((incoming: NostrEvent[]) => { |
||||
if (incoming.length === 0) return |
||||
const next = idsFromAttestations(incoming) |
||||
setAttestedIds((prev) => { |
||||
const merged = mergeAttestedPaymentIdSets(prev, next) |
||||
return merged.size === prev.size ? prev : merged |
||||
}) |
||||
}, []) |
||||
|
||||
const relayUrlsKey = useMemo( |
||||
() => |
||||
[...relayUrls] |
||||
.map((u) => u.trim()) |
||||
.filter(Boolean) |
||||
.sort() |
||||
.join('|'), |
||||
[relayUrls] |
||||
) |
||||
|
||||
useEffect(() => { |
||||
mergeAttestations(collectPaymentAttestationsFromSession()) |
||||
}, [mergeAttestations]) |
||||
|
||||
useEffect(() => { |
||||
const handleNewEvent = (data: Event) => { |
||||
const evt = (data as CustomEvent<NostrEvent>).detail |
||||
if (!evt || evt.kind !== ExtendedKind.PAYMENT_ATTESTATION) return |
||||
mergeAttestations([evt]) |
||||
} |
||||
client.addEventListener('newEvent', handleNewEvent) |
||||
return () => client.removeEventListener('newEvent', handleNewEvent) |
||||
}, [mergeAttestations]) |
||||
|
||||
useEffect(() => { |
||||
if (!relayUrlsKey) return |
||||
const urls = relayUrlsKey.split('|').filter(Boolean) |
||||
if (urls.length === 0) return |
||||
let cancelled = false |
||||
void client |
||||
.fetchEvents(urls, { kinds: [ExtendedKind.PAYMENT_ATTESTATION], limit: 500 }, { cache: true }) |
||||
.then((events) => { |
||||
if (!cancelled) mergeAttestations(events) |
||||
}) |
||||
.catch(() => { |
||||
/* optional */ |
||||
}) |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [relayUrlsKey, mergeAttestations]) |
||||
|
||||
return attestedIds |
||||
} |
||||
@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { |
||||
applyFeedGitGroupToggle, |
||||
applyFeedPostsGroupToggle, |
||||
applyFeedRepliesGroupToggle, |
||||
eventPassesNoteListKindPicker, |
||||
FEED_GIT_GROUP_KINDS, |
||||
FEED_REPLIES_GROUP_KINDS, |
||||
isFeedGitGroupEnabled, |
||||
isFeedPostsGroupEnabled, |
||||
isFeedRepliesGroupEnabled |
||||
} from '@/lib/feed-kind-filter' |
||||
import { describe, expect, it } from 'vitest' |
||||
import { kinds, type Event } from 'nostr-tools' |
||||
|
||||
function fakeEvent(partial: Partial<Event> & Pick<Event, 'kind' | 'tags'>): Event { |
||||
return { |
||||
id: '0'.repeat(64), |
||||
pubkey: 'b'.repeat(64), |
||||
created_at: 1, |
||||
content: '', |
||||
sig: 'sig', |
||||
...partial |
||||
} |
||||
} |
||||
|
||||
describe('feed kind groups', () => { |
||||
it('posts group toggles kind 1 OPs, highlights, discussions, photos, and voice posts together', () => { |
||||
const off = applyFeedPostsGroupToggle([], false) |
||||
expect(off.showKind1OPs).toBe(false) |
||||
expect(isFeedPostsGroupEnabled(off.showKind1OPs, off.showKinds)).toBe(false) |
||||
|
||||
const on = applyFeedPostsGroupToggle(off.showKinds, true) |
||||
expect(on.showKind1OPs).toBe(true) |
||||
expect(on.showKinds).toContain(kinds.Highlights) |
||||
expect(on.showKinds).toContain(ExtendedKind.DISCUSSION) |
||||
expect(on.showKinds).toContain(ExtendedKind.PICTURE) |
||||
expect(on.showKinds).toContain(ExtendedKind.VOICE) |
||||
expect(isFeedPostsGroupEnabled(on.showKind1OPs, on.showKinds)).toBe(true) |
||||
}) |
||||
|
||||
it('git group toggles all git kinds together', () => { |
||||
const off = applyFeedGitGroupToggle([], false) |
||||
expect(isFeedGitGroupEnabled(off)).toBe(false) |
||||
|
||||
const on = applyFeedGitGroupToggle(off, true) |
||||
for (const k of FEED_GIT_GROUP_KINDS) { |
||||
expect(on).toContain(k) |
||||
} |
||||
expect(isFeedGitGroupEnabled(on)).toBe(true) |
||||
}) |
||||
|
||||
it('replies group toggles kind 1 replies, comments, voice comments, and superchat kinds together', () => { |
||||
const off = applyFeedRepliesGroupToggle([], false) |
||||
expect(off.showKind1Replies).toBe(false) |
||||
expect(off.showKind1111).toBe(false) |
||||
expect(isFeedRepliesGroupEnabled(off.showKind1Replies, off.showKind1111, off.showKinds)).toBe( |
||||
false |
||||
) |
||||
|
||||
const on = applyFeedRepliesGroupToggle(off.showKinds, true) |
||||
expect(on.showKind1Replies).toBe(true) |
||||
expect(on.showKind1111).toBe(true) |
||||
expect(on.showKinds).toContain(ExtendedKind.VOICE_COMMENT) |
||||
for (const k of FEED_REPLIES_GROUP_KINDS) { |
||||
expect(on.showKinds).toContain(k) |
||||
} |
||||
expect( |
||||
isFeedRepliesGroupEnabled(on.showKind1Replies, on.showKind1111, on.showKinds) |
||||
).toBe(true) |
||||
}) |
||||
|
||||
it('hides payment notifications when replies group flags are off', () => { |
||||
const payment = fakeEvent({ |
||||
kind: ExtendedKind.PAYMENT_NOTIFICATION, |
||||
tags: [['p', 'a'.repeat(64)], ['amount', '21000']] |
||||
}) |
||||
const showKinds = [...FEED_REPLIES_GROUP_KINDS] |
||||
expect(eventPassesNoteListKindPicker(payment, showKinds, true, false, false)).toBe(false) |
||||
expect(eventPassesNoteListKindPicker(payment, showKinds, true, true, true)).toBe(true) |
||||
expect( |
||||
eventPassesNoteListKindPicker( |
||||
fakeEvent({ id: 'z'.repeat(64), kind: ExtendedKind.ZAP_RECEIPT, tags: [] }), |
||||
showKinds, |
||||
true, |
||||
true, |
||||
true |
||||
) |
||||
).toBe(true) |
||||
}) |
||||
}) |
||||
@ -1,50 +0,0 @@
@@ -1,50 +0,0 @@
|
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import { useZap } from '@/providers/ZapProvider' |
||||
import { useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function ZapReplyThresholdInput() { |
||||
const { t } = useTranslation() |
||||
const { zapReplyThreshold, updateZapReplyThreshold } = useZap() |
||||
const [zapReplyThresholdInput, setZapReplyThresholdInput] = useState(zapReplyThreshold) |
||||
|
||||
useEffect(() => { |
||||
setZapReplyThresholdInput(zapReplyThreshold) |
||||
}, [zapReplyThreshold]) |
||||
|
||||
return ( |
||||
<div className="w-full space-y-1"> |
||||
<Label htmlFor="zap-reply-threshold-input"> |
||||
<div className="text-base font-medium">{t('Zap reply threshold')}</div> |
||||
<div className="text-muted-foreground text-sm"> |
||||
{t('Zaps above this amount will appear as replies in threads')} |
||||
</div> |
||||
</Label> |
||||
<div className="flex items-center gap-2"> |
||||
<Input |
||||
id="zap-reply-threshold-input" |
||||
className="w-20" |
||||
value={zapReplyThresholdInput} |
||||
onChange={(e) => { |
||||
setZapReplyThresholdInput((pre) => { |
||||
if (e.target.value === '') { |
||||
return 0 |
||||
} |
||||
let num = parseInt(e.target.value, 10) |
||||
if (isNaN(num) || num < 0) { |
||||
num = pre |
||||
} |
||||
return num |
||||
}) |
||||
}} |
||||
onBlur={() => { |
||||
updateZapReplyThreshold(zapReplyThresholdInput) |
||||
}} |
||||
/> |
||||
<span className="text-sm text-muted-foreground shrink-0">{t('sats')}</span> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
Loading…
Reference in new issue