From 1f7d33d38c95963031ef1480c533d348a9e84254 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 23 May 2026 23:56:01 +0200 Subject: [PATCH] render monero tip receipts and allow them to be attested to and turned into superchats --- src/components/ContentPreview/index.tsx | 29 ++- src/components/KindFilter/index.tsx | 9 +- src/components/Note/MoneroTip.tsx | 203 ++++++++++++++++++ src/components/Note/Superchat.tsx | 10 +- src/components/Note/SuperchatMessageArea.tsx | 33 +++ src/components/Note/Zap.tsx | 10 +- src/components/Note/index.tsx | 16 +- .../Profile/ProfileWallSuperchats.tsx | 4 + src/components/ReplyNote/index.tsx | 28 ++- src/components/ReplyNoteList/index.tsx | 15 +- src/constants.ts | 16 ++ src/hooks/useProfileWall.tsx | 41 +++- src/i18n/locales/en.ts | 6 + src/lib/draft-event.ts | 12 +- src/lib/event.ts | 35 +-- src/lib/explore-popular-relays.ts | 3 +- src/lib/kind-description.ts | 4 + src/lib/monero-nostr-relays.test.ts | 12 ++ src/lib/monero-nostr-relays.ts | 21 ++ src/lib/monero-tip.test.ts | 65 ++++++ src/lib/monero-tip.ts | 133 ++++++++++++ src/lib/note-renderable-kinds.ts | 2 + src/lib/notification-thread-watch.ts | 8 + src/lib/superchat.ts | 66 +++++- src/lib/thread-interaction-req.ts | 11 +- src/lib/thread-reply-root-match.ts | 6 + .../primary/SpellsPage/useSpellsPageFeed.ts | 6 +- src/pages/secondary/NotePage/index.tsx | 3 + 28 files changed, 748 insertions(+), 59 deletions(-) create mode 100644 src/components/Note/MoneroTip.tsx create mode 100644 src/components/Note/SuperchatMessageArea.tsx create mode 100644 src/lib/monero-nostr-relays.test.ts create mode 100644 src/lib/monero-nostr-relays.ts create mode 100644 src/lib/monero-tip.test.ts create mode 100644 src/lib/monero-tip.ts diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index cb6ee0d6..46ab06e2 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -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({ return withKindRow() } - 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 ( +
+
{line || t('Monero tip')}
+
+ ) + } + if (previewDensity === 'compact') { + return ( +
+ +
+ ) + } + return withKindRow() + } if (forParentReplyBlurb) { const line = getParentReplyBlurbDisplayText(previewEvent) return ( diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index 19e4f55b..c2245d67 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -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' }, diff --git a/src/components/Note/MoneroTip.tsx b/src/components/Note/MoneroTip.tsx new file mode 100644 index 00000000..73fd1ba5 --- /dev/null +++ b/src/components/Note/MoneroTip.tsx @@ -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 ( +
+ [{t('Invalid Monero tip')}] +
+ ) + } + + const { senderPubkey, recipientPubkey, amountXmr, comment } = tipInfo + const attestationRecipientPubkey = actualRecipientPubkey ?? recipientPubkey ?? null + const { attested } = usePaymentAttestationStatus(event, attestationRecipientPubkey) + + const openTipTarget = (e: MouseEvent) => { + 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 ( +
+
+ {hasMetaLine ? ( +
+ {isProfileWall ? ( +
+ + + +
+ ) : ( + <> + {recipientPubkey && recipientPubkey !== senderPubkey && ( + + {t('tipped')}{' '} + + + )} + {isNotification && (isEventTip || isProfileTip) && ( + + )} + + )} +
+ ) : null} + {!isProfileWall ? ( +
+ {showAsSuperchat ? ( + <> + + {t('Superchat')} + {showAmount ? ( + + {formatXmrAmount(amountXmr)} XMR + + ) : null} + + ) : ( + <> + + {t('Monero tip')} + {showAmount ? ( + + {formatXmrAmount(amountXmr)} XMR + + ) : null} + + )} +
+ ) : null} +
+ + {isNotification ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/src/components/Note/Superchat.tsx b/src/components/Note/Superchat.tsx index b4a83753..6fe0ea42 100644 --- a/src/components/Note/Superchat.tsx +++ b/src/components/Note/Superchat.tsx @@ -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({ ) : null} - {comment ? ( - - ) : null} + {isNotification ? (
+ ) + } + + if (!showEmptyFallback) return null + + return ( +

+ {t('(No message included.)')} +

+ ) +} diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index 7738f677..50ccf96a 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -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({
) : null} - {comment ? ( - - ) : null} + {isNotification ? (
) + } else if ( + event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE || + event.kind === ExtendedKind.MONERO_TIP_RECEIPT + ) { + content = ( + + ) } else if (event.kind === ExtendedKind.FOLLOW_PACK) { content = } else if ( diff --git a/src/components/Profile/ProfileWallSuperchats.tsx b/src/components/Profile/ProfileWallSuperchats.tsx index 1dc6b1e3..ffc97054 100644 --- a/src/components/Profile/ProfileWallSuperchats.tsx +++ b/src/components/Profile/ProfileWallSuperchats.tsx @@ -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({ {superchats.map((event) => event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? ( + ) : isMoneroTipKind(event.kind) ? ( + ) : ( ) diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 45cc9c10..480744a5 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -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' 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({ ) 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({ 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({ ) : 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 ? ( ) : event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT ? ( + ) : event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE || + event.kind === ExtendedKind.MONERO_TIP_RECEIPT ? ( + ) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? ( ) : isNip18RepostKind(event.kind) ? null : ( diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index e97addc3..0f13c564 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -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({ }) 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 diff --git a/src/constants.ts b/src/constants.ts index 58a4cc58..c8283f98 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 = { 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. */ diff --git a/src/hooks/useProfileWall.tsx b/src/hooks/useProfileWall.tsx index d877273a..2652465c 100644 --- a/src/hooks/useProfileWall.tsx +++ b/src/hooks/useProfileWall.tsx @@ -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 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 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( (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 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 (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( diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7b7c2633..b7ccd237 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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", diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index d9b25452..3e66dd68 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -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[][] = [ diff --git a/src/lib/event.ts b/src/lib/event.ts index 1864de0c..76bb535b 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -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) { 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) { 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) { 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 } diff --git a/src/lib/explore-popular-relays.ts b/src/lib/explore-popular-relays.ts index 1bc2db0a..c12bd4f0 100644 --- a/src/lib/explore-popular-relays.ts +++ b/src/lib/explore-popular-relays.ts @@ -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 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()] diff --git a/src/lib/kind-description.ts b/src/lib/kind-description.ts index d386a486..32a2c297 100644 --- a/src/lib/kind-description.ts +++ b/src/lib/kind-description.ts @@ -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: diff --git a/src/lib/monero-nostr-relays.test.ts b/src/lib/monero-nostr-relays.test.ts new file mode 100644 index 00000000..27e0e34f --- /dev/null +++ b/src/lib/monero-nostr-relays.test.ts @@ -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) + }) +}) diff --git a/src/lib/monero-nostr-relays.ts b/src/lib/monero-nostr-relays.ts new file mode 100644 index 00000000..4e04c927 --- /dev/null +++ b/src/lib/monero-nostr-relays.ts @@ -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() + 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 +} diff --git a/src/lib/monero-tip.test.ts b/src/lib/monero-tip.test.ts new file mode 100644 index 00000000..8cc81f43 --- /dev/null +++ b/src/lib/monero-tip.test.ts @@ -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') + }) +}) diff --git a/src/lib/monero-tip.ts b/src/lib/monero-tip.ts new file mode 100644 index 00000000..9618b0ec --- /dev/null +++ b/src/lib/monero-tip.ts @@ -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 +} diff --git a/src/lib/note-renderable-kinds.ts b/src/lib/note-renderable-kinds.ts index 7e231ae5..072609cb 100644 --- a/src/lib/note-renderable-kinds.ts +++ b/src/lib/note-renderable-kinds.ts @@ -16,6 +16,8 @@ const RENDERABLE_NOTE_KINDS = new Set([ 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, diff --git a/src/lib/notification-thread-watch.ts b/src/lib/notification-thread-watch.ts index c2e15be6..05865bc9 100644 --- a/src/lib/notification-thread-watch.ts +++ b/src/lib/notification-thread-watch.ts @@ -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 diff --git a/src/lib/superchat.ts b/src/lib/superchat.ts index b2350a96..6b30502f 100644 --- a/src/lib/superchat.ts +++ b/src/lib/superchat.ts @@ -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 /** 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 { 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 { ) } -/** 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 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 { - 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( } 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( ) } +/** 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( attestedIds.has(e.id.toLowerCase()) ) } + if (isMoneroTipKind(e.kind)) { + return ( + isProfileWallMoneroTip(e, profilePubkey, profileEventId) && + attestedIds.has(e.id.toLowerCase()) + ) + } return false }) ) diff --git a/src/lib/thread-interaction-req.ts b/src/lib/thread-interaction-req.ts index 34748b92..312599e4 100644 --- a/src/lib/thread-interaction-req.ts +++ b/src/lib/thread-interaction-req.ts @@ -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 export function buildThreadSuperchatPriorityFilters( input: BuildThreadInteractionFiltersInput ): Filter[] { - const superchatKinds = new Set([kinds.Zap, ExtendedKind.PAYMENT_NOTIFICATION]) + const superchatKinds = new Set([ + 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)) diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts index dd98fab1..a32e4c29 100644 --- a/src/lib/thread-reply-root-match.ts +++ b/src/lib/thread-reply-root-match.ts @@ -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) diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts index 68a4d7ff..72636f1c 100644 --- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -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) { 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] diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 994262ce..b43328ac 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -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'