diff --git a/public/payto_logos/BNB.png b/public/payto_logos/BNB.png new file mode 100644 index 00000000..58748293 Binary files /dev/null and b/public/payto_logos/BNB.png differ diff --git a/public/payto_logos/EurC.png b/public/payto_logos/EurC.png new file mode 100644 index 00000000..ab9bd8db Binary files /dev/null and b/public/payto_logos/EurC.png differ diff --git a/public/payto_logos/Litecoin.png b/public/payto_logos/Litecoin.png new file mode 100644 index 00000000..fe7cbb90 Binary files /dev/null and b/public/payto_logos/Litecoin.png differ diff --git a/public/payto_logos/Monero.png b/public/payto_logos/Monero.png new file mode 100644 index 00000000..e5bc2aa0 Binary files /dev/null and b/public/payto_logos/Monero.png differ diff --git a/public/payto_logos/README.md b/public/payto_logos/README.md new file mode 100644 index 00000000..befa7498 --- /dev/null +++ b/public/payto_logos/README.md @@ -0,0 +1,7 @@ +# Payto logos + +Icons for payment types (crypto, etc.) used by payto links. + +**Supported formats:** SVG, GIF, JPG/JPEG, PNG, WebP, etc. — any format the browser can display in `` is fine. SVG scales best at different sizes. + +Filenames are mapped in `src/lib/payto.ts` → `PAYTO_LOGO_FILES`. Use whatever extension the asset has (.svg, .gif, .jpg, .png, …). diff --git a/public/payto_logos/Tron.png b/public/payto_logos/Tron.png new file mode 100644 index 00000000..96e5af7c Binary files /dev/null and b/public/payto_logos/Tron.png differ diff --git a/public/payto_logos/XRP.gif b/public/payto_logos/XRP.gif new file mode 100644 index 00000000..43e17413 Binary files /dev/null and b/public/payto_logos/XRP.gif differ diff --git a/public/payto_logos/apple_pay.webp b/public/payto_logos/apple_pay.webp new file mode 100644 index 00000000..3cfec47b Binary files /dev/null and b/public/payto_logos/apple_pay.webp differ diff --git a/public/payto_logos/bitcoin-cash-bch-logo.svg b/public/payto_logos/bitcoin-cash-bch-logo.svg new file mode 100644 index 00000000..6f541ead --- /dev/null +++ b/public/payto_logos/bitcoin-cash-bch-logo.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/public/payto_logos/buymeacoffee.png b/public/payto_logos/buymeacoffee.png new file mode 100644 index 00000000..04c88559 Binary files /dev/null and b/public/payto_logos/buymeacoffee.png differ diff --git a/public/payto_logos/cashapp.webp b/public/payto_logos/cashapp.webp new file mode 100644 index 00000000..6125c53f Binary files /dev/null and b/public/payto_logos/cashapp.webp differ diff --git a/public/payto_logos/dogecoin-doge-logo.svg b/public/payto_logos/dogecoin-doge-logo.svg new file mode 100644 index 00000000..c435731d --- /dev/null +++ b/public/payto_logos/dogecoin-doge-logo.svg @@ -0,0 +1 @@ +Dogecoin (DOGE) \ No newline at end of file diff --git a/public/payto_logos/ethereum-eth-logo.svg b/public/payto_logos/ethereum-eth-logo.svg new file mode 100644 index 00000000..684e9687 --- /dev/null +++ b/public/payto_logos/ethereum-eth-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/payto_logos/geyser_fund.webp b/public/payto_logos/geyser_fund.webp new file mode 100644 index 00000000..3f96ecd3 Binary files /dev/null and b/public/payto_logos/geyser_fund.webp differ diff --git a/public/payto_logos/github_sponsors.png b/public/payto_logos/github_sponsors.png new file mode 100644 index 00000000..c731b7cb Binary files /dev/null and b/public/payto_logos/github_sponsors.png differ diff --git a/public/payto_logos/gofundme.jpeg b/public/payto_logos/gofundme.jpeg new file mode 100644 index 00000000..d00bfc8b Binary files /dev/null and b/public/payto_logos/gofundme.jpeg differ diff --git a/public/payto_logos/google_pay.png b/public/payto_logos/google_pay.png new file mode 100644 index 00000000..a8ef9771 Binary files /dev/null and b/public/payto_logos/google_pay.png differ diff --git a/public/payto_logos/kickstarter.webp b/public/payto_logos/kickstarter.webp new file mode 100644 index 00000000..b193c2d0 Binary files /dev/null and b/public/payto_logos/kickstarter.webp differ diff --git a/public/payto_logos/ko-fi.png b/public/payto_logos/ko-fi.png new file mode 100644 index 00000000..8812cacd Binary files /dev/null and b/public/payto_logos/ko-fi.png differ diff --git a/public/payto_logos/multi-collateral-dai-dai-logo.svg b/public/payto_logos/multi-collateral-dai-dai-logo.svg new file mode 100644 index 00000000..2ae2e32f --- /dev/null +++ b/public/payto_logos/multi-collateral-dai-dai-logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/public/payto_logos/patreon.png b/public/payto_logos/patreon.png new file mode 100644 index 00000000..819c2e69 Binary files /dev/null and b/public/payto_logos/patreon.png differ diff --git a/public/payto_logos/paypal.webp b/public/payto_logos/paypal.webp new file mode 100644 index 00000000..286b1a9e Binary files /dev/null and b/public/payto_logos/paypal.webp differ diff --git a/public/payto_logos/revolut.webp b/public/payto_logos/revolut.webp new file mode 100644 index 00000000..551a546c Binary files /dev/null and b/public/payto_logos/revolut.webp differ diff --git a/public/payto_logos/solana.png b/public/payto_logos/solana.png new file mode 100644 index 00000000..fc4ca982 Binary files /dev/null and b/public/payto_logos/solana.png differ diff --git a/public/payto_logos/tether-usdt-logo.svg b/public/payto_logos/tether-usdt-logo.svg new file mode 100644 index 00000000..e5308224 --- /dev/null +++ b/public/payto_logos/tether-usdt-logo.svg @@ -0,0 +1 @@ +tether-usdt-logo \ No newline at end of file diff --git a/public/payto_logos/usd-coin-usdc-logo.svg b/public/payto_logos/usd-coin-usdc-logo.svg new file mode 100644 index 00000000..5dfea926 --- /dev/null +++ b/public/payto_logos/usd-coin-usdc-logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/payto_logos/venmo.png b/public/payto_logos/venmo.png new file mode 100644 index 00000000..5fe7131d Binary files /dev/null and b/public/payto_logos/venmo.png differ diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 419e4426..630a379d 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -15,6 +15,8 @@ import Lightbox from 'yet-another-react-lightbox' import Zoom from 'yet-another-react-lightbox/plugins/zoom' import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' import EmbeddedCitation from '@/components/EmbeddedCitation' +import { parsePaytoUri } from '@/lib/payto' +import PaytoLink from '@/components/PaytoLink' import { DeletedEventProvider } from '@/providers/DeletedEventProvider' import { ReplyProvider } from '@/providers/ReplyProvider' import Wikilink from '@/components/UniversalContent/Wikilink' @@ -753,6 +755,20 @@ export default function AsciidocArticle({ // Show as plain text if not already in a tag or placeholder return `${prefix}nostr:${bech32Id}${emptyBrackets || ''}` }) + + // payto: URIs (RFC-8905 / NIP-A3) – replace and plain payto:// with placeholder + htmlString = htmlString.replace(/]*href=["'](payto:\/\/[^"']+)["'][^>]*>([^<]*)<\/a>/gi, (_match, paytoUri, _linkText) => { + const parsed = parsePaytoUri(paytoUri) + if (!parsed) return _match + const escaped = paytoUri.replace(/"/g, '"').replace(/&/g, '&').replace(/'/g, ''') + return `` + }) + htmlString = htmlString.replace(/(^|[\s>])(payto:\/\/[a-z0-9-]+\/[^\s<\]\)\"']+)/gi, (_match, prefix, paytoUri) => { + const parsed = parsePaytoUri(paytoUri.trim()) + if (!parsed) return _match + const escaped = parsed.raw.replace(/"/g, '"').replace(/&/g, '&').replace(/'/g, ''') + return `${prefix}` + }) // Handle LaTeX math expressions from AsciiDoc stem processor // AsciiDoc with stem: latexmath outputs \(...\) for inline and \[...\] for block math @@ -1031,6 +1047,28 @@ export default function AsciidocArticle({ parent.replaceChild(textNode, container) } }) + + // Process payto: placeholders – replace with PaytoLink + const paytoPlaceholders = contentRef.current.querySelectorAll('.payto-placeholder[data-payto-uri]') + paytoPlaceholders.forEach((element) => { + const paytoUri = element.getAttribute('data-payto-uri') + if (!paytoUri) return + const decoded = paytoUri.replace(/"/g, '"').replace(/&/g, '&').replace(/'/g, "'") + const container = document.createElement('span') + container.className = 'inline' + const parent = element.parentNode + if (!parent) return + parent.replaceChild(container, element) + try { + const root = createRoot(container) + root.render() + reactRootsRef.current.set(container, root) + } catch (error) { + logger.error('Failed to render payto link', { paytoUri: decoded, error }) + const textNode = document.createTextNode(decoded) + parent.replaceChild(textNode, container) + } + }) // Process citations - replace placeholders with React components // First pass: collect all citations and assign indices diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 23f876b1..b1eef353 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -19,6 +19,8 @@ import Zoom from 'yet-another-react-lightbox/plugins/zoom' import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' import EmbeddedCitation from '@/components/EmbeddedCitation' import { preprocessMarkdownMediaLinks } from './preprocessMarkup' +import { PAYTO_URI_REGEX, parsePaytoUri } from '@/lib/payto' +import PaytoLink from '@/components/PaytoLink' import katex from 'katex' import 'katex/dist/katex.min.css' import logger from '@/lib/logger' @@ -2865,9 +2867,9 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map { if (match.index !== undefined) { - // Skip if already in code, bold, italic, strikethrough, link, relay-url, or nostr + // Skip if already in code, bold, italic, strikethrough, link, relay-url, nostr, or payto const isInOther = inlinePatterns.some(p => - (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr') && + (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') && match.index! >= p.index && match.index! < p.end ) @@ -2920,7 +2922,7 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map - (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr') && + (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') && match.index! >= p.index && match.index! < p.end ) @@ -2935,6 +2937,29 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map { + if (match.index !== undefined) { + const fullMatch = match[0] + const parsed = parsePaytoUri(fullMatch) + if (!parsed) return + const isInOther = inlinePatterns.some(p => + (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') && + match.index! >= p.index && + match.index! < p.end + ) + if (!isInOther) { + inlinePatterns.push({ + index: match.index, + end: match.index + match[0].length, + type: 'payto', + data: parsed + }) + } + } + }) // Sort by index inlinePatterns.sort((a, b) => a.index - b.index) @@ -2983,21 +3008,27 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map ) } else if (pattern.type === 'link') { - // Render markdown links as inline links (green to match theme) - // Process the link text for inline formatting (bold, italic, etc.) const { text, url } = pattern.data - const linkContent = parseInlineMarkdown(text, `${keyPrefix}-link-${i}`, _footnotes) - parts.push( - - {linkContent} - - ) + if (url.startsWith('payto://')) { + parts.push( + + {parseInlineMarkdown(text, `${keyPrefix}-link-${i}`, _footnotes)} + + ) + } else { + const linkContent = parseInlineMarkdown(text, `${keyPrefix}-link-${i}`, _footnotes) + parts.push( + + {linkContent} + + ) + } } else if (pattern.type === 'hashtag') { // Render hashtags as inline links (green to match theme) const tag = pattern.data @@ -3040,6 +3071,15 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Mapnostr:{bech32Id}) } + } else if (pattern.type === 'payto') { + const payto = pattern.data as { type: string; authority: string; raw: string } + parts.push( + + ) } lastIndex = pattern.end diff --git a/src/components/PaytoDialog/index.tsx b/src/components/PaytoDialog/index.tsx new file mode 100644 index 00000000..c50d18d4 --- /dev/null +++ b/src/components/PaytoDialog/index.tsx @@ -0,0 +1,81 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Copy } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { getPaytoTypeInfo } from '@/lib/payto' +import { Zap } from 'lucide-react' + +export default function PaytoDialog({ + open, + onOpenChange, + type, + authority, + paytoUri +}: { + open: boolean + onOpenChange: (open: boolean) => void + type: string + authority: string + paytoUri: string +}) { + const { t } = useTranslation() + const info = getPaytoTypeInfo(type) + const label = info?.label ?? type + const isLightning = type.toLowerCase() === 'lightning' + + const handleCopy = (text: string, label?: string) => { + navigator.clipboard.writeText(text) + toast.success(label ? t('Copied {{label}} address', { label }) : t('Copied to clipboard')) + onOpenChange(false) + } + + return ( + + + + + {isLightning && } + {label} + + + {isLightning + ? t('Lightning payment address – copy to pay via your wallet') + : t('Payment address – copy to use in your wallet or app')} + + +
+
+ {authority} +
+
+ + +
+
+
+
+ ) +} diff --git a/src/components/PaytoLink/index.tsx b/src/components/PaytoLink/index.tsx new file mode 100644 index 00000000..9fa960ac --- /dev/null +++ b/src/components/PaytoLink/index.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { + parsePaytoUri, + buildPaytoUri, + getPaytoTypeInfo, + getPaytoIconChar, + getPaytoLogoPath, + getPaytoProfileUrl, + isKnownPaytoType, + isLightningPaytoType +} from '@/lib/payto' +import PaytoDialog from '@/components/PaytoDialog' +import { HelpCircle } from 'lucide-react' +import { cn } from '@/lib/utils' + +export default function PaytoLink({ + paytoUri, + type: typeProp, + authority: authorityProp, + pubkey, + onOpenZap, + className, + children +}: { + paytoUri?: string + type?: string + authority?: string + /** When set with lightning type, clicking can open Zap dialog via onOpenZap */ + pubkey?: string + onOpenZap?: (pubkey: string) => void + className?: string + children?: React.ReactNode +}) { + const { t } = useTranslation() + const [dialogOpen, setDialogOpen] = useState(false) + + const parsed = paytoUri + ? parsePaytoUri(paytoUri) + : typeProp && authorityProp + ? { type: typeProp.toLowerCase(), authority: authorityProp, raw: buildPaytoUri(typeProp, authorityProp) } + : null + + if (!parsed) { + return children ? {children} : null + } + + const { type, authority, raw } = parsed + const info = getPaytoTypeInfo(type) + const known = isKnownPaytoType(type) + const isLightning = isLightningPaytoType(type) + const canZap = isLightning && !!pubkey && !!onOpenZap + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + if (canZap) { + onOpenZap(pubkey!) + return + } + if (!known) { + navigator.clipboard.writeText(raw) + toast.success(t('Copied payto address')) + return + } + setDialogOpen(true) + } + + const displayLabel = info?.label ?? type + const logoPath = getPaytoLogoPath(type) + const iconChar = getPaytoIconChar(type) + const profileUrl = getPaytoProfileUrl(type, authority) + const content = children ?? {authority} + + const iconEl = ( + + {logoPath ? ( + + ) : iconChar != null ? ( + + {iconChar} + + ) : ( + + )} + + ) + + if (profileUrl) { + return ( + e.stopPropagation()} + > + {iconEl} + {content} + + ) + } + + return ( + <> + + {known && ( + + )} + + ) +} diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 2dd59cc6..5855cbc9 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -32,9 +32,10 @@ import { toNoteList } from '@/lib/link' import { parseAdvancedSearch } from '@/lib/search-parser' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' -import { FileText, Link, Zap, Film } from 'lucide-react' +import { FileText, Link, Film, Copy } from 'lucide-react' import { useEffect, useMemo, useState, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import logger from '@/lib/logger' import NotFound from '../NotFound' import FollowedBy from './FollowedBy' @@ -49,6 +50,7 @@ import ProfileInteractions from './ProfileInteractions' import ProfileNotes from './ProfileNotes' import { toFollowPacks } from '@/lib/link' import ZapDialog from '@/components/ZapDialog' +import PaytoLink from '@/components/PaytoLink' import type { TProfile } from '@/types' type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you' | 'notes' @@ -129,10 +131,13 @@ export default function Profile({ id }: { id?: string }) { const [paymentInfo, setPaymentInfo] = useState | null>(null) const [openZapDialog, setOpenZapDialog] = useState(false) - const mergedPaymentMethods = useMemo( - () => mergePaymentMethods(paymentInfo, profile ?? null), - [paymentInfo, profile] - ) + const mergedPaymentMethods = useMemo(() => { + const list = mergePaymentMethods(paymentInfo, profile ?? null) + return [...list].sort((a, b) => { + const rank = (type: string) => (type === 'lightning' ? 0 : type === 'bitcoin' ? 1 : 2) + return rank(a.type) - rank(b.type) + }) + }, [paymentInfo, profile]) // Fetch payment info (kind 10133) for this profile useEffect(() => { @@ -476,61 +481,53 @@ export default function Profile({ id }: { id?: string }) { ))} )} - {/* Payment methods: merged from kind 10133 + profile lightning, deduplicated */} + {/* Payment methods: merged from kind 10133 + profile lightning, deduplicated – use PaytoLink for consistent behavior */} {mergedPaymentMethods.length > 0 && (
Payment Methods
- {mergedPaymentMethods.map((method, idx) => { - const authority = method.authority - const paytoUri = method.payto - const isLightning = method.type === 'lightning' - return ( -
-
{method.displayType}
- {authority && ( -
- {isLightning && } - {isLightning && pubkey ? ( - - ) : paytoUri ? ( - e.stopPropagation()} - > - {authority} - - ) : ( - {authority} - )} -
- )} - {(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && ( -
- {method.currency && ({method.currency})} - {method.minAmount !== undefined && method.maxAmount !== undefined && ( - - {method.minAmount}-{method.maxAmount} - - )} -
- )} -
- ) - })} + {mergedPaymentMethods.map((method, idx) => ( +
+
{method.displayType}
+ {method.authority && ( +
+ setOpenZapDialog(true) : undefined} + className="hover:underline break-all min-w-0 text-primary flex-1" + > + {method.authority} + + +
+ )} + {(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && ( +
+ {method.currency && ({method.currency})} + {method.minAmount !== undefined && method.maxAmount !== undefined && ( + + {method.minAmount}-{method.maxAmount} + + )} +
+ )} +
+ ))}
)} diff --git a/src/components/ProfileZapButton/index.tsx b/src/components/ProfileZapButton/index.tsx index 344c6b3d..9fac498f 100644 --- a/src/components/ProfileZapButton/index.tsx +++ b/src/components/ProfileZapButton/index.tsx @@ -28,7 +28,7 @@ export default function ProfileZapButton({ > - {!setOpenZapDialog && } + {!setOpenZapDialog && } ) } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9a7794c3..e4ba6656 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -66,6 +66,16 @@ export default { 'n relays': '{{n}} relays', Rename: 'Rename', 'Copy share link': 'Copy share link', + 'Copy address': 'Copy address', + 'Copy payto URI': 'Copy payto URI', + 'Copied payto address': 'Copied payto address', + 'Copied to clipboard': 'Copied to clipboard', + 'Copied {{label}} address': 'Copied {{label}} address', + 'Lightning payment address – copy to pay via your wallet': 'Lightning payment address – copy to pay via your wallet', + 'Payment address – copy to use in your wallet or app': 'Payment address – copy to use in your wallet or app', + 'Click to open payment options': 'Click to open payment options', + 'Click to copy address': 'Click to copy address', + 'Open on website': 'Open on website', 'Share with Jumble': 'Share with Jumble', 'Share with Alexandria': 'Share with Alexandria', Delete: 'Delete', diff --git a/src/lib/nostr-parser.tsx b/src/lib/nostr-parser.tsx index 7a6f4f3b..422eb1fa 100644 --- a/src/lib/nostr-parser.tsx +++ b/src/lib/nostr-parser.tsx @@ -9,13 +9,15 @@ import WebPreview from '@/components/WebPreview' import { BookstrContent } from '@/components/Bookstr/BookstrContent' import { cleanUrl, isImage, isMedia } from '@/lib/url' import { getImetaInfosFromEvent } from '@/lib/event' +import { parsePaytoUri } from '@/lib/payto' +import PaytoLink from '@/components/PaytoLink' import { TImetaInfo } from '@/types' import { Event } from 'nostr-tools' import logger from '@/lib/logger' export interface ParsedNostrContent { elements: Array<{ - type: 'text' | 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'gallery' | 'url' | 'jumble-note' + type: 'text' | 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'gallery' | 'url' | 'jumble-note' | 'payto' content: string bech32Id?: string nostrType?: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' @@ -28,6 +30,7 @@ export interface ParsedNostrContent { images?: TImetaInfo[] url?: string noteId?: string + paytoUri?: string }> } @@ -59,7 +62,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo // Collect all matches (nostr, URLs, hashtags, wikilinks, jumble notes, and bookstr URLs) and sort by position const allMatches: Array<{ - type: 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'url' | 'jumble-note' + type: 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'url' | 'jumble-note' | 'payto' match: RegExpExecArray start: number end: number @@ -70,6 +73,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo bookstrWikilink?: string sourceUrl?: string noteId?: string + paytoUri?: string }> = [] // Find nostr matches @@ -152,6 +156,27 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo url: cleanedUrl }) } + // payto: URI (RFC-8905 / NIP-A3) – handle as payment link, not external URL + else if (cleanedUrl.startsWith('payto://')) { + const parsed = parsePaytoUri(cleanedUrl) + if (parsed) { + allMatches.push({ + type: 'payto', + match: urlMatch, + start: urlMatch.index, + end: urlMatch.index + urlMatch[0].length, + paytoUri: parsed.raw + }) + } else { + allMatches.push({ + type: 'url', + match: urlMatch, + start: urlMatch.index, + end: urlMatch.index + urlMatch[0].length, + url: cleanedUrl + }) + } + } // Regular URL (not media) else { allMatches.push({ @@ -221,7 +246,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo let lastIndex = 0 - for (const { type, match, start, end, url, hashtag, wikilink, displayText, bookstrWikilink, sourceUrl, noteId } of allMatches) { + for (const { type, match, start, end, url, hashtag, wikilink, displayText, bookstrWikilink, sourceUrl, noteId, paytoUri } of allMatches) { // Add text before the match if (start > lastIndex) { const textContent = content.slice(lastIndex, start) @@ -303,6 +328,12 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo url: url, noteId: noteId }) + } else if (type === 'payto' && paytoUri) { + elements.push({ + type: 'payto', + content: match[0], + paytoUri: paytoUri + }) } lastIndex = end @@ -586,6 +617,16 @@ export function renderNostrContent(parsedContent: ParsedNostrContent, className? ) } + if (element.type === 'payto' && element.paytoUri) { + return ( + + ) + } + if (element.type === 'nostr' && element.bech32Id && element.nostrType) { // Render as embedded content if (element.nostrType === 'npub' || element.nostrType === 'nprofile') { diff --git a/src/lib/payto.ts b/src/lib/payto.ts new file mode 100644 index 00000000..a1400b72 --- /dev/null +++ b/src/lib/payto.ts @@ -0,0 +1,155 @@ +/** + * payto: URI handling (RFC-8905 / NIP-A3) + * Parse and normalize payto:/// URIs; known types for UI (icons, labels, dialogs). + */ + +export const PAYTO_URI_REGEX = /payto:\/\/([a-z0-9-]+)\/([^\s\]\)\<\"']+)/gi + +export interface ParsedPayto { + type: string + authority: string + raw: string +} + +/** + * Parse a payto URI into type and authority. Returns null if invalid. + */ +export function parsePaytoUri(uri: string): ParsedPayto | null { + const trimmed = uri.trim() + const m = /^payto:\/\/([a-z0-9-]+)\/(.+)$/i.exec(trimmed) + if (!m) return null + const type = m[1].toLowerCase() + const authority = decodeURIComponent(m[2].replace(/\+/g, ' ')) + if (!type || !authority) return null + return { type, authority, raw: trimmed } +} + +/** + * Build payto URI from type and authority. + */ +export function buildPaytoUri(type: string, authority: string): string { + const t = type.toLowerCase().replace(/[^a-z0-9-]/g, '') + const a = encodeURIComponent(authority.trim()) + return `payto://${t}/${a}` +} + +/** Known payment types: NIP-A3 recommended + common extras (crypto, fiat, tipping) */ +export const PAYTO_KNOWN_TYPES: Record< + string, + { label: string; shortLabel?: string; symbol?: string; category: 'crypto' | 'fiat' | 'lightning' | 'tip' } +> = { + bitcoin: { label: 'Bitcoin', shortLabel: 'BTC', symbol: '₿', category: 'crypto' }, + lightning: { label: 'Lightning Network', shortLabel: 'LBTC', symbol: '⚡', category: 'lightning' }, + ethereum: { label: 'Ethereum', shortLabel: 'ETH', symbol: 'Ξ', category: 'crypto' }, + monero: { label: 'Monero', shortLabel: 'XMR', symbol: 'ɱ', category: 'crypto' }, + nano: { label: 'Nano', shortLabel: 'XNO', symbol: 'Ӿ', category: 'crypto' }, + cashme: { label: 'Cash App', shortLabel: 'Cash App', symbol: '$', category: 'fiat' }, + revolut: { label: 'Revolut', shortLabel: 'Revolut', symbol: '💳', category: 'fiat' }, + venmo: { label: 'Venmo', shortLabel: 'Venmo', symbol: '$', category: 'fiat' }, + + // Common crypto + dogecoin: { label: 'Dogecoin', shortLabel: 'DOGE', symbol: 'Ð', category: 'crypto' }, + litecoin: { label: 'Litecoin', shortLabel: 'LTC', symbol: 'Ł', category: 'crypto' }, + usdt: { label: 'Tether', shortLabel: 'USDT', symbol: '₮', category: 'crypto' }, + usdc: { label: 'USD Coin', shortLabel: 'USDC', symbol: '◎', category: 'crypto' }, + dai: { label: 'Dai', shortLabel: 'DAI', symbol: '◈', category: 'crypto' }, + euroc: { label: 'Euro Coin', shortLabel: 'EUROC', symbol: '€', category: 'crypto' }, + solana: { label: 'Solana', shortLabel: 'SOL', symbol: '◎', category: 'crypto' }, + + // Tipping / donation + paypal: { label: 'PayPal', shortLabel: 'PayPal', symbol: '💙', category: 'fiat' }, + buymeacoffee: { label: 'Buy Me a Coffee', shortLabel: 'Buy Me a Coffee', symbol: '☕', category: 'tip' }, + 'ko-fi': { label: 'Ko-fi', shortLabel: 'Ko-fi', symbol: '☕', category: 'tip' }, + kofi: { label: 'Ko-fi', shortLabel: 'Ko-fi', symbol: '☕', category: 'tip' }, + patreon: { label: 'Patreon', shortLabel: 'Patreon', symbol: '🎭', category: 'tip' }, + github: { label: 'GitHub Sponsors', shortLabel: 'GitHub', symbol: '🐙', category: 'tip' }, + + // Fiat / wallets + 'apple-pay': { label: 'Apple Pay', shortLabel: 'Apple Pay', symbol: '🍎', category: 'fiat' }, + 'google-pay': { label: 'Google Pay', shortLabel: 'Google Pay', symbol: 'G', category: 'fiat' }, + + // Crowdfunding / fundraising + geyser: { label: 'Geyser Fund', shortLabel: 'Geyser', symbol: '⛲', category: 'tip' }, + gofundme: { label: 'GoFundMe', shortLabel: 'GoFundMe', symbol: '🎯', category: 'tip' }, + kickstarter: { label: 'Kickstarter', shortLabel: 'Kickstarter', symbol: '🚀', category: 'tip' } +} + +/** Icon character/symbol for known types; null for unknown (render HelpCircle or ?) */ +export function getPaytoIconChar(type: string): string | null { + const info = getPaytoTypeInfo(type) + return info?.symbol ?? null +} + +/** Logo filename in /payto_logos/ for types that have an asset. Any image format works: .svg, .gif, .jpg, .png, .webp, etc. */ +export const PAYTO_LOGO_FILES: Record = { + ethereum: 'ethereum-eth-logo.svg', + monero: 'Monero.png', + litecoin: 'Litecoin.png', + dogecoin: 'dogecoin-doge-logo.svg', + usdt: 'tether-usdt-logo.svg', + usdc: 'usd-coin-usdc-logo.svg', + dai: 'multi-collateral-dai-dai-logo.svg', + euroc: 'EurC.png', + solana: 'solana.png', + bnb: 'BNB.png', + tron: 'Tron.png', + xrp: 'XRP.gif', + 'bitcoin-cash': 'bitcoin-cash-bch-logo.svg', + cashme: 'cashapp.webp', + venmo: 'venmo.png', + paypal: 'paypal.webp', + revolut: 'revolut.webp', + buymeacoffee: 'buymeacoffee.png', + 'ko-fi': 'ko-fi.png', + kofi: 'ko-fi.png', + patreon: 'patreon.png', + github: 'github_sponsors.png', + 'apple-pay': 'apple_pay.webp', + 'google-pay': 'google_pay.png', + geyser: 'geyser_fund.webp', + gofundme: 'gofundme.jpeg', + kickstarter: 'kickstarter.webp' +} + +/** Profile/page URL template for types that have a web profile. Use {authority} as placeholder. Null = no direct link. */ +export const PAYTO_PROFILE_URL_TEMPLATES: Record = { + paypal: 'https://paypal.me/{authority}', + venmo: 'https://venmo.com/{authority}', + revolut: 'https://revolut.me/{authority}', + buymeacoffee: 'https://buymeacoffee.com/{authority}', + 'ko-fi': 'https://ko-fi.com/{authority}', + kofi: 'https://ko-fi.com/{authority}', + patreon: 'https://patreon.com/{authority}', + github: 'https://github.com/sponsors/{authority}', + geyser: 'https://geyser.fund/project/{authority}', + gofundme: 'https://www.gofundme.com/f/{authority}', + kickstarter: 'https://www.kickstarter.com/projects/{authority}', + cashme: 'https://cash.app/{authority}' +} + +export function getPaytoProfileUrl(type: string, authority: string): string | null { + const key = type.toLowerCase() + const template = PAYTO_PROFILE_URL_TEMPLATES[key] + if (!template || !authority) return null + return template.replace('{authority}', encodeURIComponent(authority.trim())) +} + +export function getPaytoLogoPath(type: string): string | null { + const key = type.toLowerCase() + const file = PAYTO_LOGO_FILES[key] + if (!file) return null + return `/payto_logos/${file}` +} + +export function getPaytoTypeInfo(type: string): (typeof PAYTO_KNOWN_TYPES)[string] | undefined { + return PAYTO_KNOWN_TYPES[type.toLowerCase()] +} + +export function isKnownPaytoType(type: string): boolean { + return type.toLowerCase() in PAYTO_KNOWN_TYPES +} + +/** Check if type is lightning (opens Zap flow when pubkey available) */ +export function isLightningPaytoType(type: string): boolean { + return type.toLowerCase() === 'lightning' +} diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index 7c83146d..dea0d128 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -9,7 +9,7 @@ import { Slider } from '@/components/ui/slider' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Checkbox } from '@/components/ui/checkbox' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Hash, X, Users, Trophy, Film, Image, Zap, Settings, Book, Eye, Edit3, ChevronDown, Check, ImageUp, Smile } from 'lucide-react' +import { Hash, X, Users, Film, Image, Zap, Settings, Book, Eye, Edit3, ChevronDown, Check, ImageUp, Smile } from 'lucide-react' import { useState, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from '@/providers/NostrProvider'