Browse Source

render monero tip receipts and allow them to be attested to and turned into superchats

imwald
Silberengel 3 weeks ago
parent
commit
1f7d33d38c
  1. 29
      src/components/ContentPreview/index.tsx
  2. 9
      src/components/KindFilter/index.tsx
  3. 203
      src/components/Note/MoneroTip.tsx
  4. 10
      src/components/Note/Superchat.tsx
  5. 33
      src/components/Note/SuperchatMessageArea.tsx
  6. 10
      src/components/Note/Zap.tsx
  7. 16
      src/components/Note/index.tsx
  8. 4
      src/components/Profile/ProfileWallSuperchats.tsx
  9. 28
      src/components/ReplyNote/index.tsx
  10. 15
      src/components/ReplyNoteList/index.tsx
  11. 16
      src/constants.ts
  12. 41
      src/hooks/useProfileWall.tsx
  13. 6
      src/i18n/locales/en.ts
  14. 12
      src/lib/draft-event.ts
  15. 35
      src/lib/event.ts
  16. 3
      src/lib/explore-popular-relays.ts
  17. 4
      src/lib/kind-description.ts
  18. 12
      src/lib/monero-nostr-relays.test.ts
  19. 21
      src/lib/monero-nostr-relays.ts
  20. 65
      src/lib/monero-tip.test.ts
  21. 133
      src/lib/monero-tip.ts
  22. 2
      src/lib/note-renderable-kinds.ts
  23. 8
      src/lib/notification-thread-watch.ts
  24. 66
      src/lib/superchat.ts
  25. 11
      src/lib/thread-interaction-req.ts
  26. 6
      src/lib/thread-reply-root-match.ts
  27. 6
      src/pages/primary/SpellsPage/useSpellsPageFeed.ts
  28. 3
      src/pages/secondary/NotePage/index.tsx

29
src/components/ContentPreview/index.tsx

@ -39,6 +39,7 @@ import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay' @@ -39,6 +39,7 @@ import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay'
import NoteKindLabel from '../Note/NoteKindLabel'
import EventPowLabel from '../EventPowLabel'
import Zap from '../Note/Zap'
import MoneroTip from '../Note/MoneroTip'
import GitRepublicEventCard from '../Note/GitRepublicEventCard'
/** Inert event so hooks can run before `event` is defined. */
@ -303,7 +304,33 @@ export default function ContentPreview({ @@ -303,7 +304,33 @@ export default function ContentPreview({
return withKindRow(<ZapPreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap) {
if (
event.kind === ExtendedKind.ZAP_RECEIPT ||
event.kind === kinds.Zap ||
event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT
) {
if (
event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT
) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Monero tip')}</div>
</div>
)
}
if (previewDensity === 'compact') {
return (
<div className={cn('min-w-0', previewOuter)}>
<MoneroTip event={previewEvent} className={previewBody} />
</div>
)
}
return withKindRow(<MoneroTip event={previewEvent} variant="thread" />)
}
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (

9
src/components/KindFilter/index.tsx

@ -27,7 +27,14 @@ const KIND_FILTER_OPTIONS = [ @@ -27,7 +27,14 @@ const KIND_FILTER_OPTIONS = [
{ kindGroup: [ExtendedKind.DISCUSSION], label: 'Discussions' },
{ kindGroup: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], label: 'Calendar Events' },
{ kindGroup: [...LIVE_ACTIVITY_KINDS], label: 'Live streams' },
{ kindGroup: [ExtendedKind.ZAP_RECEIPT], label: 'Zaps' },
{
kindGroup: [
ExtendedKind.ZAP_RECEIPT,
ExtendedKind.MONERO_TIP_DISCLOSURE,
ExtendedKind.MONERO_TIP_RECEIPT
],
label: 'Zaps'
},
{ kindGroup: [kinds.Repost, ExtendedKind.GENERIC_REPOST], label: 'Boosts' },
{ kindGroup: [ExtendedKind.GIT_REPO_ANNOUNCEMENT], label: 'Git repositories' },
{ kindGroup: [ExtendedKind.GIT_ISSUE], label: 'Git issues' },

203
src/components/Note/MoneroTip.tsx

@ -0,0 +1,203 @@ @@ -0,0 +1,203 @@
import { useFetchEvent } from '@/hooks'
import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus'
import { shouldHideInteractions } from '@/lib/event-filtering'
import {
formatXmrAmount,
getMoneroTipInfo,
getMoneroTipReferenceFetchId
} from '@/lib/monero-tip'
import { openNoteFromFetchOrCache } from '@/lib/navigation-related-events'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { getSuperchatPaytoType } from '@/lib/superchat'
import {
superchatChromePaymentChipClass,
superchatChromePaymentIconClass,
superchatChromeRowClass,
superchatTitleClass
} from '@/lib/superchat-ui'
import { toProfile } from '@/lib/link'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager'
import Username from '../Username'
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel'
import SuperchatMessageArea from './SuperchatMessageArea'
import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
import UserAvatar from '../UserAvatar'
import type { SuperchatLayoutVariant } from './Superchat'
export default function MoneroTip({
event,
className,
variant = 'thread'
}: {
event: Event
className?: string
variant?: SuperchatLayoutVariant
}) {
const { t } = useTranslation()
const tipInfo = useMemo(() => getMoneroTipInfo(event), [event])
const relayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const fetchOpts = useMemo(
() => (relayHints.length ? { relayHints } : undefined),
[relayHints]
)
const referencedFetchId = useMemo(
() => (tipInfo ? getMoneroTipReferenceFetchId(tipInfo) : undefined),
[tipInfo]
)
const { event: targetEvent } = useFetchEvent(referencedFetchId, undefined, fetchOpts)
const isEventTip = Boolean(targetEvent || tipInfo?.eventId || tipInfo?.referencedCoordinate)
const isProfileTip = Boolean(!isEventTip && tipInfo?.recipientPubkey)
const actualRecipientPubkey = useMemo(() => {
if (isEventTip && targetEvent) return targetEvent.pubkey
if (isProfileTip) return tipInfo?.recipientPubkey ?? undefined
return tipInfo?.recipientPubkey ?? undefined
}, [isEventTip, isProfileTip, targetEvent, tipInfo?.recipientPubkey])
const paytoType = useMemo(() => getSuperchatPaytoType(event), [event])
const { navigateToNote } = useSmartNoteNavigationOptional()
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
const inQuietMode = targetEvent ? shouldHideInteractions(targetEvent) : false
if (inQuietMode) return null
if (!tipInfo || !tipInfo.senderPubkey) {
return (
<div className={cn('py-0.5 text-sm text-muted-foreground', className)}>
[{t('Invalid Monero tip')}]
</div>
)
}
const { senderPubkey, recipientPubkey, amountXmr, comment } = tipInfo
const attestationRecipientPubkey = actualRecipientPubkey ?? recipientPubkey ?? null
const { attested } = usePaymentAttestationStatus(event, attestationRecipientPubkey)
const openTipTarget = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
if (isEventTip && referencedFetchId) {
openNoteFromFetchOrCache(navigateToNote, referencedFetchId, targetEvent)
} else if (recipientPubkey) {
push(toProfile(recipientPubkey))
}
}
const isNotification = variant === 'notification'
const isProfileWall = variant === 'profileWall'
const showAmount = isNotification && amountXmr != null && amountXmr > 0
const showAsSuperchat = isProfileWall || attested
const hasMetaLine =
isProfileWall ||
(isNotification &&
((recipientPubkey && recipientPubkey !== senderPubkey) || isEventTip || isProfileTip))
return (
<div className={cn('min-w-0', className)}>
<div className="text-sm text-muted-foreground">
{hasMetaLine ? (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm">
{isProfileWall ? (
<div className="flex min-w-0 items-center gap-2">
<UserAvatar userId={senderPubkey} size="small" className="shrink-0" />
<Username
userId={senderPubkey}
showAt
className="min-w-0 font-medium text-foreground/85 hover:text-foreground"
/>
<SuperchatPaymentMethodLabel
paytoType={paytoType}
className="shrink-0"
imgClassName="size-5"
/>
</div>
) : (
<>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<span>
<span>{t('tipped')}</span>{' '}
<Username
userId={recipientPubkey}
className="inline font-medium text-foreground/85 hover:text-foreground"
/>
</span>
)}
{isNotification && (isEventTip || isProfileTip) && (
<button
type="button"
onClick={openTipTarget}
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{isEventTip
? t('Monero tip note')
: isProfileTip
? t('Monero tip profile')
: t('Monero tip')}
</button>
)}
</>
)}
</div>
) : null}
{!isProfileWall ? (
<div
className={cn(
'flex flex-wrap items-center gap-x-2 gap-y-1',
hasMetaLine && 'mt-1'
)}
>
{showAsSuperchat ? (
<>
<SuperchatPaymentMethodLabel
paytoType={paytoType}
className={superchatChromePaymentChipClass}
imgClassName={superchatChromePaymentIconClass}
/>
<span className={cn(superchatChromeRowClass, superchatTitleClass)}>{t('Superchat')}</span>
{showAmount ? (
<span className="text-sm font-bold tabular-nums tracking-tight text-foreground">
{formatXmrAmount(amountXmr)} XMR
</span>
) : null}
</>
) : (
<>
<SuperchatPaymentMethodLabel
paytoType={paytoType}
className={superchatChromePaymentChipClass}
imgClassName={superchatChromePaymentIconClass}
/>
<span className="text-sm font-semibold text-foreground">{t('Monero tip')}</span>
{showAmount ? (
<span className="text-sm font-bold tabular-nums tracking-tight text-foreground">
{formatXmrAmount(amountXmr)} XMR
</span>
) : null}
</>
)}
</div>
) : null}
</div>
<SuperchatMessageArea
event={event}
comment={comment}
showEmptyFallback={showAsSuperchat || isNotification}
/>
{isNotification ? (
<div className="text-sm text-muted-foreground">
<TurnIntoSuperchatButton
event={event}
prominent
attestationRecipientPubkey={attestationRecipientPubkey}
className="mt-3"
/>
</div>
) : null}
</div>
)
}

10
src/components/Note/Superchat.tsx

@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next' @@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next'
import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager'
import Username from '../Username'
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel'
import SuperchatCommentMarkdown from './SuperchatCommentMarkdown'
import SuperchatMessageArea from './SuperchatMessageArea'
import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
import UserAvatar from '../UserAvatar'
@ -169,9 +169,11 @@ export default function Superchat({ @@ -169,9 +169,11 @@ export default function Superchat({
</div>
) : null}
</div>
{comment ? (
<SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" />
) : null}
<SuperchatMessageArea
event={event}
comment={comment}
showEmptyFallback={showAsSuperchat}
/>
{isNotification ? (
<div className="text-sm text-muted-foreground">
<TurnIntoSuperchatButton

33
src/components/Note/SuperchatMessageArea.tsx

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import SuperchatCommentMarkdown from './SuperchatCommentMarkdown'
export default function SuperchatMessageArea({
event,
comment,
showEmptyFallback,
className
}: {
event: Event
comment?: string
showEmptyFallback: boolean
className?: string
}) {
const { t } = useTranslation()
const trimmed = comment?.trim()
if (trimmed) {
return (
<SuperchatCommentMarkdown event={event} comment={trimmed} className={cn('mt-2', className)} />
)
}
if (!showEmptyFallback) return null
return (
<p className={cn('mt-2 text-sm italic text-muted-foreground/75', className)}>
{t('(No message included.)')}
</p>
)
}

10
src/components/Note/Zap.tsx

@ -21,7 +21,7 @@ import { useTranslation } from 'react-i18next' @@ -21,7 +21,7 @@ import { useTranslation } from 'react-i18next'
import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager'
import Username from '../Username'
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel'
import SuperchatCommentMarkdown from './SuperchatCommentMarkdown'
import SuperchatMessageArea from './SuperchatMessageArea'
import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
import UserAvatar from '../UserAvatar'
import type { SuperchatLayoutVariant } from './Superchat'
@ -184,9 +184,11 @@ export default function Zap({ @@ -184,9 +184,11 @@ export default function Zap({
</div>
) : null}
</div>
{comment ? (
<SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" />
) : null}
<SuperchatMessageArea
event={event}
comment={comment}
showEmptyFallback={showAsSuperchat}
/>
{isNotification ? (
<div className="text-sm text-muted-foreground">
<TurnIntoSuperchatButton

16
src/components/Note/index.tsx

@ -81,6 +81,7 @@ import VideoNote from './VideoNote' @@ -81,6 +81,7 @@ import VideoNote from './VideoNote'
import RelayReview from './RelayReview'
import Superchat from './Superchat'
import Zap from './Zap'
import MoneroTip from './MoneroTip'
import CitationCard from '@/components/CitationCard'
import FollowPackPreview from '../ContentPreview/FollowPackPreview'
import CalendarEventContent from '../CalendarEventContent'
@ -262,7 +263,9 @@ export default function Note({ @@ -262,7 +263,9 @@ export default function Note({
if (
event.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
event.kind === ExtendedKind.ZAP_RECEIPT ||
event.kind === ExtendedKind.ZAP_REQUEST
event.kind === ExtendedKind.ZAP_REQUEST ||
event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT
) {
return undefined
}
@ -592,6 +595,17 @@ export default function Note({ @@ -592,6 +595,17 @@ export default function Note({
variant={showPaymentAttestationAction ? 'notification' : 'thread'}
/>
)
} else if (
event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT
) {
content = (
<MoneroTip
className="mt-2"
event={displayEvent}
variant={showPaymentAttestationAction ? 'notification' : 'thread'}
/>
)
} else if (event.kind === ExtendedKind.FOLLOW_PACK) {
content = <FollowPackPreview className="mt-2" event={displayEvent} />
} else if (

4
src/components/Profile/ProfileWallSuperchats.tsx

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
import Superchat from '@/components/Note/Superchat'
import Zap from '@/components/Note/Zap'
import MoneroTip from '@/components/Note/MoneroTip'
import { ExtendedKind } from '@/constants'
import { isMoneroTipKind } from '@/lib/monero-tip'
import { superchatSectionHeadingClass } from '@/lib/superchat-ui'
import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'
@ -48,6 +50,8 @@ export default function ProfileWallSuperchats({ @@ -48,6 +50,8 @@ export default function ProfileWallSuperchats({
{superchats.map((event) =>
event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
<Superchat key={event.id} event={event} variant="profileWall" />
) : isMoneroTipKind(event.kind) ? (
<MoneroTip key={event.id} event={event} variant="profileWall" />
) : (
<Zap key={event.id} event={event} variant="profileWall" />
)

28
src/components/ReplyNote/index.tsx

@ -12,6 +12,7 @@ import { @@ -12,6 +12,7 @@ import {
DISCUSSION_UPVOTE_DISPLAY
} from '@/lib/discussion-votes'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { getMoneroTipInfo } from '@/lib/monero-tip'
import { isMentioningMutedUsers, isNip18RepostKind, isNip25ReactionKind } from '@/lib/event'
import { getWebExternalReactionTargetUrl } from '@/lib/rss-article'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
@ -40,6 +41,7 @@ import Username from '../Username' @@ -40,6 +41,7 @@ import Username from '../Username'
import NoteKindLabel from '../Note/NoteKindLabel'
import Superchat from '../Note/Superchat'
import Zap from '../Note/Zap'
import MoneroTip from '../Note/MoneroTip'
export default function ReplyNote({
event,
@ -72,9 +74,18 @@ export default function ReplyNote({ @@ -72,9 +74,18 @@ export default function ReplyNote({
)
const parentFetchRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const headerUserId = useMemo(() => {
if (event.kind !== kinds.Zap) return event.pubkey
const info = getZapInfoFromEvent(event)
return info?.senderPubkey ?? event.pubkey
if (event.kind === kinds.Zap) {
const info = getZapInfoFromEvent(event)
return info?.senderPubkey ?? event.pubkey
}
if (
event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT
) {
const info = getMoneroTipInfo(event)
return info?.senderPubkey ?? event.pubkey
}
return event.pubkey
}, [event])
const show = useMemo(() => {
@ -153,7 +164,9 @@ export default function ReplyNote({ @@ -153,7 +164,9 @@ export default function ReplyNote({
className={cn(
(isNip25ReactionKind(event.kind) ||
event.kind === kinds.Zap ||
event.kind === ExtendedKind.PAYMENT_NOTIFICATION) &&
event.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT) &&
'opacity-60'
)}
/>
@ -166,7 +179,9 @@ export default function ReplyNote({ @@ -166,7 +179,9 @@ export default function ReplyNote({
) : parentEventId &&
event.kind !== kinds.Zap &&
event.kind !== ExtendedKind.PAYMENT_NOTIFICATION &&
event.kind !== ExtendedKind.ZAP_RECEIPT ? (
event.kind !== ExtendedKind.ZAP_RECEIPT &&
event.kind !== ExtendedKind.MONERO_TIP_DISCLOSURE &&
event.kind !== ExtendedKind.MONERO_TIP_RECEIPT ? (
<ParentNotePreview
appearance="subtle"
className="mt-1.5"
@ -205,6 +220,9 @@ export default function ReplyNote({ @@ -205,6 +220,9 @@ export default function ReplyNote({
</div>
) : event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT ? (
<Zap className="mt-1.5" event={event} variant="thread" />
) : event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT ? (
<MoneroTip className="mt-1.5" event={event} variant="thread" />
) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
<Superchat className="mt-1.5" event={event} variant="thread" />
) : isNip18RepostKind(event.kind) ? null : (

15
src/components/ReplyNoteList/index.tsx

@ -46,6 +46,7 @@ import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' @@ -46,6 +46,7 @@ import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { collectProfilePubkeysFromEvents } from '@/lib/profile-batch-coordinator'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { appendMoneroNostrRelays } from '@/lib/monero-nostr-relays'
import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed'
@ -803,12 +804,14 @@ function ReplyNoteList({ @@ -803,12 +804,14 @@ function ReplyNoteList({
})
const relayUrlsForThreadReq = sanitizeRelayUrlsForFetch(
feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], {
operation: 'read',
blockedRelays: replyBlockedRelays,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: false
})
appendMoneroNostrRelays(
feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], {
operation: 'read',
blockedRelays: replyBlockedRelays,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: false
})
)
)
threadRelayUrlsRef.current = relayUrlsForThreadReq
const recipientPubkey = event.pubkey

16
src/constants.ts

@ -490,6 +490,18 @@ export const FAST_WRITE_RELAY_URLS = [ @@ -490,6 +490,18 @@ export const FAST_WRITE_RELAY_URLS = [
'wss://nos.lol'
]
/**
* Paid Monero Nostr relays (PMNR) and Nosmero tip-disclosure relay.
* @see https://pmnr.xmr.rocks/
*/
export const MONERO_NOSTR_RELAY_URLS = [
'wss://xmr.usenostr.org',
'wss://nostr.xmr.rocks',
'wss://nerostr.xmr.rocks',
'wss://xmr.ithurtswhenip.ee',
'wss://nosmero.com/nip78-relay'
] as const
/** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish.
* Publish to all of these so GIFs are discoverable across clients; some may be temporarily down. */
export const GIF_RELAY_URLS = [
@ -556,6 +568,10 @@ export const ExtendedKind = { @@ -556,6 +568,10 @@ export const ExtendedKind = {
PAYMENT_NOTIFICATION: 9740,
/** Payment Superchats: recipient attests receipt of kind 9740 or 9735 (kind 9741). */
PAYMENT_ATTESTATION: 9741,
/** Nosmero Monero tip disclosure (custom). */
MONERO_TIP_DISCLOSURE: 9736,
/** Garnet Monero tip receipt with on-chain proof in JSON content. */
MONERO_TIP_RECEIPT: 1814,
PUBLICATION: 30040,
WIKI_ARTICLE: 30818,
/** NIP/spec document (Markdown) for relay publication instead of GitHub; kind 30817. */

41
src/hooks/useProfileWall.tsx

@ -5,6 +5,7 @@ import { @@ -5,6 +5,7 @@ import {
} from '@/constants'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import { appendMoneroNostrRelays } from '@/lib/monero-nostr-relays'
import { getReplaceableCoordinate } from '@/lib/event'
import {
fetchLegacyProfileBadgesListEvent,
@ -99,6 +100,8 @@ function buildProfileWallSuperchatFilters(pkNorm: string, profileId: string | un @@ -99,6 +100,8 @@ function buildProfileWallSuperchatFilters(pkNorm: string, profileId: string | un
const filters: Filter[] = [
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#p': [pkNorm], limit: 200 },
{ kinds: [kinds.Zap], '#p': [pkNorm], limit: 200 },
{ kinds: [ExtendedKind.MONERO_TIP_DISCLOSURE], '#p': [pkNorm], limit: 200 },
{ kinds: [ExtendedKind.MONERO_TIP_RECEIPT], '#p': [pkNorm], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pkNorm], limit: 500 }
]
if (profileId) {
@ -110,7 +113,9 @@ function buildProfileWallSuperchatFilters(pkNorm: string, profileId: string | un @@ -110,7 +113,9 @@ function buildProfileWallSuperchatFilters(pkNorm: string, profileId: string | un
filters.push(
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#e': [profileId], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#a': [profileCoord], limit: 200 },
{ kinds: [kinds.Zap], '#e': [profileId], limit: 200 }
{ kinds: [kinds.Zap], '#e': [profileId], limit: 200 },
{ kinds: [ExtendedKind.MONERO_TIP_DISCLOSURE], '#e': [profileId], limit: 200 },
{ kinds: [ExtendedKind.MONERO_TIP_RECEIPT], '#e': [profileId], limit: 200 }
)
}
return filters
@ -196,7 +201,9 @@ async function hydrateProfileWallSuperchatsFromLocalCache( @@ -196,7 +201,9 @@ async function hydrateProfileWallSuperchatsFromLocalCache(
(e) =>
(e.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
e.kind === kinds.Zap ||
e.kind === ExtendedKind.ZAP_RECEIPT) &&
e.kind === ExtendedKind.ZAP_RECEIPT ||
e.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
e.kind === ExtendedKind.MONERO_TIP_RECEIPT) &&
!isEventDeleted(e)
)
@ -442,14 +449,24 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine @@ -442,14 +449,24 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
const authorRl = await client.peekRelayListFromStorage(pubkey).catch(() => emptyAuthor)
if (cancelled) return
const relayUrls = buildProfilePageReadRelayUrls(
favoriteRelaysRef.current,
blockedRelaysRef.current,
authorRl,
false,
false,
[ExtendedKind.COMMENT, ExtendedKind.PROFILE_BADGES_LIST, ExtendedKind.BADGE_DEFINITION, ExtendedKind.PAYMENT_NOTIFICATION, ExtendedKind.PAYMENT_ATTESTATION],
useGlobalRelayBootstrapRef.current
const relayUrls = appendMoneroNostrRelays(
buildProfilePageReadRelayUrls(
favoriteRelaysRef.current,
blockedRelaysRef.current,
authorRl,
false,
false,
[
ExtendedKind.COMMENT,
ExtendedKind.PROFILE_BADGES_LIST,
ExtendedKind.BADGE_DEFINITION,
ExtendedKind.PAYMENT_NOTIFICATION,
ExtendedKind.PAYMENT_ATTESTATION,
ExtendedKind.MONERO_TIP_DISCLOSURE,
ExtendedKind.MONERO_TIP_RECEIPT
],
useGlobalRelayBootstrapRef.current
)
)
const localWall = await hydrateProfileWallFromLocalCache(
@ -588,7 +605,9 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine @@ -588,7 +605,9 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
(e) =>
(e.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
e.kind === kinds.Zap ||
e.kind === ExtendedKind.ZAP_RECEIPT) &&
e.kind === ExtendedKind.ZAP_RECEIPT ||
e.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
e.kind === ExtendedKind.MONERO_TIP_RECEIPT) &&
!isEventDeletedRef.current(e)
)
wallSuperchats = filterAttestedProfileWallSuperchats(

6
src/i18n/locales/en.ts

@ -221,6 +221,12 @@ export default { @@ -221,6 +221,12 @@ export default {
"Superchats": "Superchats",
"Profile wall superchats": "Profile wall superchats",
"Invalid superchat": "Invalid superchat",
"Invalid Monero tip": "Invalid Monero tip",
"Monero tip": "Monero tip",
"Monero tip note": "Tipped note",
"Monero tip profile": "Tipped profile",
tipped: "tipped",
"(No message included.)": "(No message included.)",
"Turn this into a superchat!": "Turn this into a superchat!",
"Superchat attested": "Superchat attested",
"Confirmed by recipient": "Confirmed by recipient",

12
src/lib/draft-event.ts

@ -601,11 +601,15 @@ export async function createPaymentAttestationDraftEvent( @@ -601,11 +601,15 @@ export async function createPaymentAttestationDraftEvent(
const targetKind =
targetEvent.kind === ExtendedKind.PAYMENT_NOTIFICATION
? String(ExtendedKind.PAYMENT_NOTIFICATION)
: targetEvent.kind === kinds.Zap || targetEvent.kind === ExtendedKind.ZAP_RECEIPT
? String(ExtendedKind.ZAP_RECEIPT)
: null
: targetEvent.kind === ExtendedKind.MONERO_TIP_DISCLOSURE
? String(ExtendedKind.MONERO_TIP_DISCLOSURE)
: targetEvent.kind === ExtendedKind.MONERO_TIP_RECEIPT
? String(ExtendedKind.MONERO_TIP_RECEIPT)
: targetEvent.kind === kinds.Zap || targetEvent.kind === ExtendedKind.ZAP_RECEIPT
? String(ExtendedKind.ZAP_RECEIPT)
: null
if (!targetKind) {
throw new Error('Only zap receipts and payment notifications can be attested')
throw new Error('Only zap receipts, Monero tips, and payment notifications can be attested')
}
const tags: string[][] = [

35
src/lib/event.ts

@ -214,7 +214,9 @@ export function getParentETag(event?: Event) { @@ -214,7 +214,9 @@ export function getParentETag(event?: Event) {
if (
event.kind === kinds.Zap ||
event.kind === ExtendedKind.ZAP_RECEIPT ||
event.kind === ExtendedKind.PAYMENT_NOTIFICATION
event.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT
) {
const firstHex = getFirstHexEventIdFromETags(event.tags)
if (firstHex) {
@ -250,7 +252,9 @@ export function getParentATag(event?: Event) { @@ -250,7 +252,9 @@ export function getParentATag(event?: Event) {
if (
event.kind === kinds.Zap ||
event.kind === ExtendedKind.ZAP_RECEIPT ||
event.kind === ExtendedKind.PAYMENT_NOTIFICATION
event.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT
) {
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A'))
}
@ -291,8 +295,13 @@ export function getRootETag(event?: Event) { @@ -291,8 +295,13 @@ export function getRootETag(event?: Event) {
return event.tags.find(tagNameEquals('E'))
}
// Kind 9735: thread root for note zaps is the zapped event id on `e` / `E`
if (event.kind === kinds.Zap) {
// Kind 9735 / 9736 / 1814: thread root for note tips is the referenced event id on `e` / `E`
if (
event.kind === kinds.Zap ||
event.kind === ExtendedKind.ZAP_RECEIPT ||
event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT
) {
const firstHex = getFirstHexEventIdFromETags(event.tags)
if (firstHex) {
return (
@ -300,14 +309,16 @@ export function getRootETag(event?: Event) { @@ -300,14 +309,16 @@ export function getRootETag(event?: Event) {
event.tags.find((t) => t[0] === 'E' && t[1] === firstHex)
)
}
const zapped = getZapInfoFromEvent(event)?.originalEventId
if (zapped && /^[0-9a-f]{64}$/i.test(zapped)) {
const hex = zapped.toLowerCase()
return (
event.tags.find((t) => t[0] === 'e' && t[1]?.toLowerCase() === hex) ??
event.tags.find((t) => t[0] === 'E' && t[1]?.toLowerCase() === hex) ??
['e', hex]
)
if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) {
const zapped = getZapInfoFromEvent(event)?.originalEventId
if (zapped && /^[0-9a-f]{64}$/i.test(zapped)) {
const hex = zapped.toLowerCase()
return (
event.tags.find((t) => t[0] === 'e' && t[1]?.toLowerCase() === hex) ??
event.tags.find((t) => t[0] === 'E' && t[1]?.toLowerCase() === hex) ??
['e', hex]
)
}
}
return undefined
}

3
src/lib/explore-popular-relays.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants'
import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS, MONERO_NOSTR_RELAY_URLS } from '@/constants'
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import { normalizeAnyRelayUrl } from '@/lib/url'
import type { ViewerRelayListLike } from '@/lib/viewer-relay-defaults'
@ -43,6 +43,7 @@ export function buildExplorePopularRelayUrls(options: BuildExplorePopularRelayUr @@ -43,6 +43,7 @@ export function buildExplorePopularRelayUrls(options: BuildExplorePopularRelayUr
for (const u of options.favoriteRelays) bump(u)
for (const u of DEFAULT_FAVORITE_RELAYS) bump(u)
for (const u of FAST_READ_RELAY_URLS) bump(u)
for (const u of MONERO_NOSTR_RELAY_URLS) bump(u)
for (const u of options.nip66CachedUrls ?? []) bump(u)
const ranked = [...counts.entries()]

4
src/lib/kind-description.ts

@ -82,6 +82,10 @@ export function getKindDescription( @@ -82,6 +82,10 @@ export function getKindDescription(
return { number: 9734, description: 'Zap request' }
case ExtendedKind.ZAP_RECEIPT:
return { number: 9735, description: 'Zap receipt' }
case ExtendedKind.MONERO_TIP_DISCLOSURE:
return { number: 9736, description: 'Monero tip' }
case ExtendedKind.MONERO_TIP_RECEIPT:
return { number: 1814, description: 'Monero tip' }
case ExtendedKind.PAYMENT_NOTIFICATION:
return { number: 9740, description: 'Payment notification' }
case ExtendedKind.PAYMENT_ATTESTATION:

12
src/lib/monero-nostr-relays.test.ts

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { appendMoneroNostrRelays } from './monero-nostr-relays'
describe('appendMoneroNostrRelays', () => {
it('appends PMNR relays without duplicates', () => {
const out = appendMoneroNostrRelays(['wss://nostr.xmr.rocks', 'wss://relay.damus.io'])
expect(out[0]).toBe('wss://nostr.xmr.rocks')
expect(out[1]).toBe('wss://relay.damus.io')
expect(out.filter((u) => u.includes('xmr')).length).toBeGreaterThan(1)
expect(new Set(out.map((u) => u.toLowerCase())).size).toBe(out.length)
})
})

21
src/lib/monero-nostr-relays.ts

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
import { MONERO_NOSTR_RELAY_URLS } from '@/constants'
import { normalizeAnyRelayUrl } from '@/lib/url'
/** Append PMNR / Nosmero relays when not already present (order preserved). */
export function appendMoneroNostrRelays(urls: readonly string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const raw of urls) {
const k = normalizeAnyRelayUrl(raw) || raw.trim()
if (!k || seen.has(k)) continue
seen.add(k)
out.push(raw)
}
for (const raw of MONERO_NOSTR_RELAY_URLS) {
const k = normalizeAnyRelayUrl(raw) || raw.trim()
if (!k || seen.has(k)) continue
seen.add(k)
out.push(raw)
}
return out
}

65
src/lib/monero-tip.test.ts

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
import { ExtendedKind } from '@/constants'
import { describe, expect, it } from 'vitest'
import {
formatXmrAmount,
getMoneroTipInfo,
getMoneroTipSortAmount,
isMoneroTipKind
} from './monero-tip'
describe('monero-tip', () => {
it('recognizes monero tip kinds', () => {
expect(isMoneroTipKind(ExtendedKind.MONERO_TIP_DISCLOSURE)).toBe(true)
expect(isMoneroTipKind(ExtendedKind.MONERO_TIP_RECEIPT)).toBe(true)
expect(isMoneroTipKind(1)).toBe(false)
})
it('parses Nosmero kind 9736', () => {
const event = {
id: 'a'.repeat(64),
pubkey: 'b'.repeat(64),
created_at: 1,
kind: ExtendedKind.MONERO_TIP_DISCLOSURE,
tags: [
['e', 'c'.repeat(64)],
['p', 'd'.repeat(64)],
['P', 'b'.repeat(64)],
['amount', '0.5'],
['txid', 'e'.repeat(64)],
['verified', 'true']
],
content: 'thanks',
sig: 'f'.repeat(128)
}
const info = getMoneroTipInfo(event)
expect(info?.amountXmr).toBe(0.5)
expect(info?.verified).toBe(true)
expect(info?.comment).toBe('thanks')
expect(info?.recipientPubkey).toBe('d'.repeat(64))
})
it('parses Garnet kind 1814 JSON content', () => {
const event = {
id: 'a'.repeat(64),
pubkey: 'b'.repeat(64),
created_at: 1,
kind: ExtendedKind.MONERO_TIP_RECEIPT,
tags: [['e', 'c'.repeat(64)], ['p', 'd'.repeat(64)]],
content: JSON.stringify({
txid: 'e'.repeat(64),
proofs: { proof1: ['4addr'] },
message: 'hello'
}),
sig: 'f'.repeat(128)
}
const info = getMoneroTipInfo(event)
expect(info?.comment).toBe('hello')
expect(info?.txid).toBe('e'.repeat(64))
expect(getMoneroTipSortAmount(event)).toBe(0)
})
it('formats XMR amounts', () => {
expect(formatXmrAmount(0.5)).toContain('0.5')
expect(formatXmrAmount(2)).toContain('2')
})
})

133
src/lib/monero-tip.ts

@ -0,0 +1,133 @@ @@ -0,0 +1,133 @@
import { ExtendedKind } from '@/constants'
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
import { Event } from 'nostr-tools'
export type MoneroTipInfo = {
senderPubkey: string
recipientPubkey: string | null
eventId?: string
referencedCoordinate?: string
/** Parsed XMR amount when present (9736 `amount` tag). */
amountXmr?: number
comment?: string
txid?: string
verified?: boolean
}
function firstTagValue(tags: string[][], names: readonly string[]): string | undefined {
for (const tag of tags) {
const name = tag[0]
const value = tag[1]?.trim()
if (value && names.includes(name)) return value
}
return undefined
}
function allTagValues(tags: string[][], name: string): string[] {
const out: string[] = []
for (const tag of tags) {
if (tag[0] === name && tag[1]?.trim()) out.push(tag[1].trim())
}
return out
}
function parseAmountXmr(raw: string | undefined): number | undefined {
if (!raw) return undefined
const n = parseFloat(raw)
if (!Number.isFinite(n) || n <= 0) return undefined
return n
}
type GarnetTipProof = {
txid?: string
txId?: string
message?: string | null
}
function parseGarnetTipProof(content: string): GarnetTipProof | null {
const trimmed = content.trim()
if (!trimmed.startsWith('{')) return null
try {
const parsed = JSON.parse(trimmed) as GarnetTipProof
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
export function isMoneroTipKind(kind: number): boolean {
return kind === ExtendedKind.MONERO_TIP_DISCLOSURE || kind === ExtendedKind.MONERO_TIP_RECEIPT
}
export function getMoneroTipInfo(event: Event): MoneroTipInfo | null {
if (event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE) {
const recipientPubkey = firstTagValue(event.tags, ['p']) ?? null
const senderPubkey =
firstTagValue(event.tags, ['P'])?.toLowerCase() ?? event.pubkey.toLowerCase()
const amountTag = firstTagValue(event.tags, ['amount'])
const amountXmr = parseAmountXmr(amountTag)
const verifiedTag = firstTagValue(event.tags, ['verified'])
const verified = verifiedTag?.toLowerCase() === 'true'
const eTag = event.tags.find((t) => t[0] === 'e' || t[0] === 'E')
const originalEventId = eTag?.[1]
const eventId = eTag ? generateBech32IdFromETag(eTag) ?? originalEventId : undefined
const aTag = event.tags.find((t) => t[0] === 'a' || t[0] === 'A')
const referencedCoordinate = aTag?.[1]
const comment = event.content?.trim() || undefined
return {
senderPubkey,
recipientPubkey,
eventId,
referencedCoordinate,
amountXmr,
comment,
txid: firstTagValue(event.tags, ['txid']),
verified
}
}
if (event.kind === ExtendedKind.MONERO_TIP_RECEIPT) {
const proof = parseGarnetTipProof(event.content)
const recipientPubkeys = allTagValues(event.tags, 'p')
const recipientPubkey = recipientPubkeys[0] ?? null
const eTag = event.tags.find((t) => t[0] === 'e' || t[0] === 'E')
const originalEventId = eTag?.[1]
const eventId = eTag ? generateBech32IdFromETag(eTag) ?? originalEventId : undefined
const aTag = event.tags.find((t) => t[0] === 'a' || t[0] === 'A')
const referencedCoordinate = aTag?.[1]
const message = proof?.message?.trim()
return {
senderPubkey: event.pubkey.toLowerCase(),
recipientPubkey,
eventId,
referencedCoordinate,
comment: message || undefined,
txid: proof?.txid ?? proof?.txId
}
}
return null
}
/** Sort key for superchat ordering (higher = larger tip). */
export function getMoneroTipSortAmount(event: Event): number {
const info = getMoneroTipInfo(event)
if (!info?.amountXmr) return 0
return Math.round(info.amountXmr * 1e8)
}
export function formatXmrAmount(amountXmr: number): string {
if (!Number.isFinite(amountXmr) || amountXmr <= 0) return '0'
if (amountXmr >= 1) {
return amountXmr.toLocaleString('en-US', { maximumFractionDigits: 4 })
}
return amountXmr.toLocaleString('en-US', { maximumFractionDigits: 6 })
}
export function getMoneroTipReferenceFetchId(info: MoneroTipInfo): string | undefined {
if (info.eventId) return info.eventId
if (info.referencedCoordinate) {
return generateBech32IdFromATag(['a', info.referencedCoordinate]) ?? undefined
}
return undefined
}

2
src/lib/note-renderable-kinds.ts

@ -16,6 +16,8 @@ const RENDERABLE_NOTE_KINDS = new Set<number>([ @@ -16,6 +16,8 @@ const RENDERABLE_NOTE_KINDS = new Set<number>([
ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.ZAP_REQUEST,
ExtendedKind.ZAP_RECEIPT,
ExtendedKind.MONERO_TIP_DISCLOSURE,
ExtendedKind.MONERO_TIP_RECEIPT,
ExtendedKind.PAYMENT_NOTIFICATION,
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.FOLLOW_PACK,

8
src/lib/notification-thread-watch.ts

@ -111,6 +111,14 @@ export function isNotificationThreadInteractionEvent(event: Event): boolean { @@ -111,6 +111,14 @@ export function isNotificationThreadInteractionEvent(event: Event): boolean {
(t) => t[0] === 'e' || t[0] === 'E' || t[0] === 'a' || t[0] === 'A'
)
}
if (
event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT
) {
return event.tags.some(
(t) => t[0] === 'e' || t[0] === 'E' || t[0] === 'a' || t[0] === 'A'
)
}
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) return true
if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) return true
if (event.kind === ExtendedKind.POLL_RESPONSE) return true

66
src/lib/superchat.ts

@ -4,12 +4,18 @@ import { @@ -4,12 +4,18 @@ import {
getReplaceableCoordinate,
normalizeReplaceableCoordinateString
} from '@/lib/event'
import {
getMoneroTipInfo,
getMoneroTipReferenceFetchId,
getMoneroTipSortAmount,
isMoneroTipKind
} from '@/lib/monero-tip'
import { hexPubkeysEqual } from '@/lib/pubkey'
import { parsePaytoTagType } from '@/lib/payto'
import { generateBech32IdFromATag } from '@/lib/tag'
import { Event, kinds } from 'nostr-tools'
export const PAYMENT_ATTESTATION_TARGET_KINDS = new Set(['9735', '9740'])
export const PAYMENT_ATTESTATION_TARGET_KINDS = new Set(['9735', '9740', '9736', '1814'])
export type PaymentNotificationInfo = {
senderPubkey: string
@ -111,6 +117,7 @@ export function getPaymentNotificationInfo(event: Event): PaymentNotificationInf @@ -111,6 +117,7 @@ export function getPaymentNotificationInfo(event: Event): PaymentNotificationInf
/** Payment category for superchat display (9735 → lightning). */
export function getSuperchatPaytoType(event: Event): string {
if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) return 'lightning'
if (isMoneroTipKind(event.kind)) return 'monero'
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
const payto = getPaymentNotificationInfo(event)?.payto
return payto ? parsePaytoTagType(payto) : 'unknown'
@ -137,8 +144,20 @@ export function getSuperchatAmountSats(event: Event): number { @@ -137,8 +144,20 @@ export function getSuperchatAmountSats(event: Event): number {
return 0
}
/** Comparable sort weight for mixed lightning / monero superchats. */
export function getSuperchatSortAmount(event: Event): number {
const sats = getSuperchatAmountSats(event)
if (sats > 0) return sats
return getMoneroTipSortAmount(event)
}
export function isSuperchatKind(kind: number): boolean {
return kind === kinds.Zap || kind === ExtendedKind.ZAP_RECEIPT || kind === ExtendedKind.PAYMENT_NOTIFICATION
return (
kind === kinds.Zap ||
kind === ExtendedKind.ZAP_RECEIPT ||
kind === ExtendedKind.PAYMENT_NOTIFICATION ||
isMoneroTipKind(kind)
)
}
/** Kinds that may be `#e` parents in the thread nested-reply relay pass (replies to zaps were missing). */
@ -151,11 +170,14 @@ export function isNestedThreadReplyParentKind(kind: number): boolean { @@ -151,11 +170,14 @@ export function isNestedThreadReplyParentKind(kind: number): boolean {
)
}
/** Recipient pubkey for a kind 9735 or 9740 payment the user may attest to. */
/** Recipient pubkey for a kind 9735, 9740, 9736, or 1814 payment the user may attest to. */
export function getSuperchatPaymentRecipientPubkey(event: Event): string | null {
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
return getPaymentNotificationInfo(event)?.recipientPubkey ?? null
}
if (isMoneroTipKind(event.kind)) {
return getMoneroTipInfo(event)?.recipientPubkey ?? firstTagValue(event.tags, ['p']) ?? null
}
if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) {
return getZapInfoFromEvent(event)?.recipientPubkey ?? firstTagValue(event.tags, ['p']) ?? null
}
@ -189,6 +211,12 @@ export function getSuperchatAttestationTargetKindValue(event: Event): string | n @@ -189,6 +211,12 @@ export function getSuperchatAttestationTargetKindValue(event: Event): string | n
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
return String(ExtendedKind.PAYMENT_NOTIFICATION)
}
if (event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE) {
return String(ExtendedKind.MONERO_TIP_DISCLOSURE)
}
if (event.kind === ExtendedKind.MONERO_TIP_RECEIPT) {
return String(ExtendedKind.MONERO_TIP_RECEIPT)
}
if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) {
return String(ExtendedKind.ZAP_RECEIPT)
}
@ -206,8 +234,8 @@ export function isAttestedSuperchat(event: Event, attestedIds: ReadonlySet<strin @@ -206,8 +234,8 @@ export function isAttestedSuperchat(event: Event, attestedIds: ReadonlySet<strin
export function sortSuperchatsByAmountDesc(events: Event[]): Event[] {
return [...events].sort((a, b) => {
const sa = getSuperchatAmountSats(a)
const sb = getSuperchatAmountSats(b)
const sa = getSuperchatSortAmount(a)
const sb = getSuperchatSortAmount(b)
if (sb !== sa) return sb - sa
return b.created_at - a.created_at
})
@ -253,6 +281,12 @@ export function partitionAttestedSuperchats( @@ -253,6 +281,12 @@ export function partitionAttestedSuperchats(
}
continue
}
if (isMoneroTipKind(e.kind)) {
if (isAttestedSuperchat(e, attestedIds) && getMoneroTipInfo(e)) {
superchats.push(e)
}
continue
}
if (e.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
if (isAttestedSuperchat(e, attestedIds) && getPaymentNotificationInfo(e)) {
superchats.push(e)
@ -312,6 +346,22 @@ export function isProfileWallPaymentNotification( @@ -312,6 +346,22 @@ export function isProfileWallPaymentNotification(
)
}
/** Kind 9736 / 1814 profile tip on a wall: `p` is the profile owner and there is no note/thread reference. */
export function isProfileWallMoneroTip(event: Event, profilePubkey: string, profileEventId?: string): boolean {
if (!isMoneroTipKind(event.kind)) return false
const info = getMoneroTipInfo(event)
if (!info?.recipientPubkey || !hexPubkeysEqual(info.recipientPubkey, profilePubkey)) {
return false
}
const referencedEventId = event.tags.find((t) => t[0] === 'e' || t[0] === 'E')?.[1]?.trim().toLowerCase()
return isProfileWallThreadReference(
referencedEventId,
info.referencedCoordinate,
profilePubkey,
profileEventId
)
}
/** Kind 9735 profile zap on a wall: `p` is the profile owner and there is no note/thread reference. */
export function isProfileWallZapReceipt(
event: Event,
@ -350,6 +400,12 @@ export function filterAttestedProfileWallSuperchats( @@ -350,6 +400,12 @@ export function filterAttestedProfileWallSuperchats(
attestedIds.has(e.id.toLowerCase())
)
}
if (isMoneroTipKind(e.kind)) {
return (
isProfileWallMoneroTip(e, profilePubkey, profileEventId) &&
attestedIds.has(e.id.toLowerCase())
)
}
return false
})
)

11
src/lib/thread-interaction-req.ts

@ -35,7 +35,9 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte @@ -35,7 +35,9 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
kinds.Zap,
ExtendedKind.PAYMENT_NOTIFICATION
ExtendedKind.PAYMENT_NOTIFICATION,
ExtendedKind.MONERO_TIP_DISCLOSURE,
ExtendedKind.MONERO_TIP_RECEIPT
])
const kindsPrimaryThread = kindsNoteCommentVoiceZap
const kindsUpperEThread = sortedUniqueKinds([
@ -97,7 +99,12 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte @@ -97,7 +99,12 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte
export function buildThreadSuperchatPriorityFilters(
input: BuildThreadInteractionFiltersInput
): Filter[] {
const superchatKinds = new Set<number>([kinds.Zap, ExtendedKind.PAYMENT_NOTIFICATION])
const superchatKinds = new Set<number>([
kinds.Zap,
ExtendedKind.PAYMENT_NOTIFICATION,
ExtendedKind.MONERO_TIP_DISCLOSURE,
ExtendedKind.MONERO_TIP_RECEIPT
])
const out: Filter[] = []
for (const filter of buildThreadInteractionFilters(input)) {
const kindsList = filter.kinds?.filter((k) => superchatKinds.has(k))

6
src/lib/thread-reply-root-match.ts

@ -85,6 +85,12 @@ function replyParentIsSuperchatToThreadHex( @@ -85,6 +85,12 @@ function replyParentIsSuperchatToThreadHex(
return hexNoteParticipatesInThread(zapped.toLowerCase(), rootHexLower, localByHex)
}
if (parentEv.kind === ExtendedKind.MONERO_TIP_DISCLOSURE || parentEv.kind === ExtendedKind.MONERO_TIP_RECEIPT) {
const tipped = parentEv.tags.find((t) => t[0] === 'e' || t[0] === 'E')?.[1]
if (!tipped || !/^[0-9a-f]{64}$/i.test(tipped)) return false
return hexNoteParticipatesInThread(tipped.toLowerCase(), rootHexLower, localByHex)
}
const ref = getPaymentNotificationInfo(parentEv)?.referencedEventId
if (!ref || !/^[0-9a-f]{64}$/i.test(ref)) return false
return hexNoteParticipatesInThread(ref.toLowerCase(), rootHexLower, localByHex)

6
src/pages/primary/SpellsPage/useSpellsPageFeed.ts

@ -43,6 +43,7 @@ import { @@ -43,6 +43,7 @@ import {
import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service'
import type { TFeedSubRequest } from '@/types'
import { isFollowFeedFauxSpellId } from './fauxSpellConfig'
import { appendMoneroNostrRelays } from '@/lib/monero-nostr-relays'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
/** `fetchReplaceableEvent(kind 3)` / relay-list hydration can hang; never block the Following spell on it. */
@ -405,9 +406,10 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { @@ -405,9 +406,10 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
if (selectedFauxSpell === 'notifications') {
if (!notificationsFeedPubkey || !feedUrls.length) return []
const base = buildNotificationsSpellSubRequests(feedUrls, notificationsFeedPubkey)
const notificationUrls = appendMoneroNostrRelays(feedUrls)
const base = buildNotificationsSpellSubRequests(notificationUrls, notificationsFeedPubkey)
const extra = buildNotificationsFollowedThreadSubRequests(
feedUrls,
notificationUrls,
notificationEventsIFollowListEvent ?? null
)
return [...base, ...extra]

3
src/pages/secondary/NotePage/index.tsx

@ -270,6 +270,9 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -270,6 +270,9 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
return 'Note: Calendar Event'
case 9735: // ExtendedKind.ZAP_RECEIPT
return 'Note: Zap Receipt'
case 9736: // ExtendedKind.MONERO_TIP_DISCLOSURE
case 1814: // ExtendedKind.MONERO_TIP_RECEIPT
return 'Note: Monero Tip'
case 6: // kinds.Repost (Nostr boost)
case 16: // ExtendedKind.GENERIC_REPOST (NIP-18)
return 'Note: Boost'

Loading…
Cancel
Save