19 changed files with 474 additions and 176 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
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