28 changed files with 748 additions and 59 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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