28 changed files with 748 additions and 59 deletions
@ -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> |
||||
) |
||||
} |
||||
@ -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> |
||||
) |
||||
} |
||||
@ -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) |
||||
}) |
||||
}) |
||||
@ -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 |
||||
} |
||||
@ -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') |
||||
}) |
||||
}) |
||||
@ -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 |
||||
} |
||||
Loading…
Reference in new issue