Browse Source

fully-implement payto:// addresses in all content and in the profile

imwald
Silberengel 2 months ago
parent
commit
d9be5a02cf
  1. BIN
      public/payto_logos/BNB.png
  2. BIN
      public/payto_logos/EurC.png
  3. BIN
      public/payto_logos/Litecoin.png
  4. BIN
      public/payto_logos/Monero.png
  5. 7
      public/payto_logos/README.md
  6. BIN
      public/payto_logos/Tron.png
  7. BIN
      public/payto_logos/XRP.gif
  8. BIN
      public/payto_logos/apple_pay.webp
  9. 17
      public/payto_logos/bitcoin-cash-bch-logo.svg
  10. BIN
      public/payto_logos/buymeacoffee.png
  11. BIN
      public/payto_logos/cashapp.webp
  12. 1
      public/payto_logos/dogecoin-doge-logo.svg
  13. 21
      public/payto_logos/ethereum-eth-logo.svg
  14. BIN
      public/payto_logos/geyser_fund.webp
  15. BIN
      public/payto_logos/github_sponsors.png
  16. BIN
      public/payto_logos/gofundme.jpeg
  17. BIN
      public/payto_logos/google_pay.png
  18. BIN
      public/payto_logos/kickstarter.webp
  19. BIN
      public/payto_logos/ko-fi.png
  20. 13
      public/payto_logos/multi-collateral-dai-dai-logo.svg
  21. BIN
      public/payto_logos/patreon.png
  22. BIN
      public/payto_logos/paypal.webp
  23. BIN
      public/payto_logos/revolut.webp
  24. BIN
      public/payto_logos/solana.png
  25. 1
      public/payto_logos/tether-usdt-logo.svg
  26. 5
      public/payto_logos/usd-coin-usdc-logo.svg
  27. BIN
      public/payto_logos/venmo.png
  28. 38
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  29. 74
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  30. 81
      src/components/PaytoDialog/index.tsx
  31. 137
      src/components/PaytoLink/index.tsx
  32. 109
      src/components/Profile/index.tsx
  33. 2
      src/components/ProfileZapButton/index.tsx
  34. 10
      src/i18n/locales/en.ts
  35. 47
      src/lib/nostr-parser.tsx
  36. 155
      src/lib/payto.ts
  37. 2
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

BIN
public/payto_logos/BNB.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/payto_logos/EurC.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
public/payto_logos/Litecoin.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/payto_logos/Monero.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

7
public/payto_logos/README.md

@ -0,0 +1,7 @@ @@ -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 `<img>` 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, …).

BIN
public/payto_logos/Tron.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/payto_logos/XRP.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
public/payto_logos/apple_pay.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

17
public/payto_logos/bitcoin-cash-bch-logo.svg

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 788 788" style="enable-background:new 0 0 788 788;" xml:space="preserve">
<style type="text/css">
.st0{fill:#0AC18E;}
.st1{fill:#FFFFFF;}
</style>
<circle class="st0" cx="394" cy="394" r="394"/>
<path id="symbol_1_" class="st1" d="M516.9,261.7c-19.8-44.9-65.3-54.5-121-45.2L378,147.1L335.8,158l17.6,69.2
c-11.1,2.8-22.5,5.2-33.8,8.4L302,166.8l-42.2,10.9l17.9,69.4c-9.1,2.6-85.2,22.1-85.2,22.1l11.6,45.2c0,0,31-8.7,30.7-8
c17.2-4.5,25.3,4.1,29.1,12.2l49.2,190.2c0.6,5.5-0.4,14.9-12.2,18.1c0.7,0.4-30.7,7.9-30.7,7.9l4.6,52.7c0,0,75.4-19.3,85.3-21.8
l18.1,70.2l42.2-10.9l-18.1-70.7c11.6-2.7,22.9-5.5,33.9-8.4l18,70.3l42.2-10.9l-18.1-70.1c65-15.8,110.9-56.8,101.5-119.5
c-6-37.8-47.3-68.8-81.6-72.3C519.3,324.7,530,297.4,516.9,261.7L516.9,261.7z M496.6,427.2c8.4,62.1-77.9,69.7-106.4,77.2
l-24.8-92.9C394,404,482.4,372.5,496.6,427.2z M444.6,300.7c8.9,55.2-64.9,61.6-88.7,67.7l-22.6-84.3
C357.2,278.2,426.5,249.6,444.6,300.7z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/payto_logos/buymeacoffee.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/payto_logos/cashapp.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

1
public/payto_logos/dogecoin-doge-logo.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 56 KiB

21
public/payto_logos/ethereum-eth-logo.svg

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW 2019 (64-Bit) -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" height="100%" version="1.1" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd"
viewBox="0 0 784.37 1277.39"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<g id="Layer_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_1421394342400">
<g>
<polygon fill="#343434" fill-rule="nonzero" points="392.07,0 383.5,29.11 383.5,873.74 392.07,882.29 784.13,650.54 "/>
<polygon fill="#8C8C8C" fill-rule="nonzero" points="392.07,0 -0,650.54 392.07,882.29 392.07,472.33 "/>
<polygon fill="#3C3C3B" fill-rule="nonzero" points="392.07,956.52 387.24,962.41 387.24,1263.28 392.07,1277.38 784.37,724.89 "/>
<polygon fill="#8C8C8C" fill-rule="nonzero" points="392.07,1277.38 392.07,956.52 -0,724.89 "/>
<polygon fill="#141414" fill-rule="nonzero" points="392.07,882.29 784.13,650.54 392.07,472.33 "/>
<polygon fill="#393939" fill-rule="nonzero" points="0,650.54 392.07,882.29 392.07,472.33 "/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/payto_logos/geyser_fund.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
public/payto_logos/github_sponsors.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/payto_logos/gofundme.jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
public/payto_logos/google_pay.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/payto_logos/kickstarter.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/payto_logos/ko-fi.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

13
public/payto_logos/multi-collateral-dai-dai-logo.svg

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW 2019 (64-Bit) -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" height="100%" version="1.1" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd"
viewBox="0 0 444.44 444.44"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<g id="Layer_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<path fill="#F5AC37" fill-rule="nonzero" d="M222.22 0c122.74,0 222.22,99.5 222.22,222.22 0,122.74 -99.48,222.22 -222.22,222.22 -122.72,0 -222.22,-99.49 -222.22,-222.22 0,-122.72 99.5,-222.22 222.22,-222.22z"/>
<path fill="#FEFEFD" fill-rule="nonzero" d="M230.41 237.91l84.44 0c1.8,0 2.65,0 2.78,-2.36 0.69,-8.59 0.69,-17.23 0,-25.83 0,-1.67 -0.83,-2.36 -2.64,-2.36l-168.05 0c-2.08,0 -2.64,0.69 -2.64,2.64l0 24.72c0,3.19 0,3.19 3.33,3.19l82.78 0zm77.79 -59.44c0.24,-0.63 0.24,-1.32 0,-1.94 -1.41,-3.07 -3.08,-6 -5.02,-8.75 -2.92,-4.7 -6.36,-9.03 -10.28,-12.92 -1.85,-2.35 -3.99,-4.46 -6.39,-6.25 -12.02,-10.23 -26.31,-17.47 -41.67,-21.11 -7.75,-1.74 -15.67,-2.57 -23.61,-2.5l-74.58 0c-2.08,0 -2.36,0.83 -2.36,2.64l0 49.3c0,2.08 0,2.64 2.64,2.64l160.27 0c0,0 1.39,-0.28 1.67,-1.11l-0.68 0zm0 88.33c-2.36,-0.26 -4.74,-0.26 -7.1,0l-154.02 0c-2.08,0 -2.78,0 -2.78,2.78l0 48.2c0,2.22 0,2.78 2.78,2.78l71.11 0c3.4,0.26 6.8,0.02 10.13,-0.69 10.32,-0.74 20.47,-2.98 30.15,-6.67 3.52,-1.22 6.92,-2.81 10.13,-4.72l0.97 0c16.67,-8.67 30.21,-22.29 38.75,-39.01 0,0 0.97,-2.1 -0.12,-2.65zm-191.81 78.75l0 -0.83 0 -32.36 0 -10.97 0 -32.64c0,-1.81 0,-2.08 -2.22,-2.08l-30.14 0c-1.67,0 -2.36,0 -2.36,-2.22l0 -26.39 32.22 0c1.8,0 2.5,0 2.5,-2.36l0 -26.11c0,-1.67 0,-2.08 -2.22,-2.08l-30.14 0c-1.67,0 -2.36,0 -2.36,-2.22l0 -24.44c0,-1.53 0,-1.94 2.22,-1.94l29.86 0c2.08,0 2.64,0 2.64,-2.64l0 -74.86c0,-2.22 0,-2.78 2.78,-2.78l104.16 0c7.56,0.3 15.07,1.13 22.5,2.5 15.31,2.83 30.02,8.3 43.47,16.11 8.92,5.25 17.13,11.59 24.44,18.89 5.5,5.71 10.46,11.89 14.86,18.47 4.37,6.67 8,13.8 10.85,21.25 0.35,1.94 2.21,3.25 4.15,2.92l24.86 0c3.19,0 3.19,0 3.33,3.06l0 22.78c0,2.22 -0.83,2.78 -3.06,2.78l-19.17 0c-1.94,0 -2.5,0 -2.36,2.5 0.76,8.46 0.76,16.95 0,25.41 0,2.36 0,2.64 2.65,2.64l21.93 0c0.97,1.25 0,2.5 0,3.76 0.14,1.61 0.14,3.24 0,4.85l0 16.81c0,2.36 -0.69,3.06 -2.78,3.06l-26.25 0c-1.83,-0.35 -3.61,0.82 -4.03,2.64 -6.25,16.25 -16.25,30.82 -29.17,42.5 -4.72,4.25 -9.68,8.25 -14.86,11.94 -5.56,3.2 -10.97,6.53 -16.67,9.17 -10.49,4.72 -21.49,8.2 -32.78,10.41 -10.72,1.92 -21.59,2.79 -32.5,2.64l-96.39 0 0 -0.14z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
public/payto_logos/patreon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
public/payto_logos/paypal.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
public/payto_logos/revolut.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/payto_logos/solana.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

1
public/payto_logos/tether-usdt-logo.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 339.43 295.27"><title>tether-usdt-logo</title><path d="M62.15,1.45l-61.89,130a2.52,2.52,0,0,0,.54,2.94L167.95,294.56a2.55,2.55,0,0,0,3.53,0L338.63,134.4a2.52,2.52,0,0,0,.54-2.94l-61.89-130A2.5,2.5,0,0,0,275,0H64.45a2.5,2.5,0,0,0-2.3,1.45h0Z" style="fill:#50af95;fill-rule:evenodd"/><path d="M191.19,144.8v0c-1.2.09-7.4,0.46-21.23,0.46-11,0-18.81-.33-21.55-0.46v0c-42.51-1.87-74.24-9.27-74.24-18.13s31.73-16.25,74.24-18.15v28.91c2.78,0.2,10.74.67,21.74,0.67,13.2,0,19.81-.55,21-0.66v-28.9c42.42,1.89,74.08,9.29,74.08,18.13s-31.65,16.24-74.08,18.12h0Zm0-39.25V79.68h59.2V40.23H89.21V79.68H148.4v25.86c-48.11,2.21-84.29,11.74-84.29,23.16s36.18,20.94,84.29,23.16v82.9h42.78V151.83c48-2.21,84.12-11.73,84.12-23.14s-36.09-20.93-84.12-23.15h0Zm0,0h0Z" style="fill:#fff;fill-rule:evenodd"/></svg>

After

Width:  |  Height:  |  Size: 874 B

5
public/payto_logos/usd-coin-usdc-logo.svg

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
<svg data-name="86977684-12db-4850-8f30-233a7c267d11" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2000 2000">
<path d="M1000 2000c554.17 0 1000-445.83 1000-1000S1554.17 0 1000 0 0 445.83 0 1000s445.83 1000 1000 1000z" fill="#2775ca"/>
<path d="M1275 1158.33c0-145.83-87.5-195.83-262.5-216.66-125-16.67-150-50-150-108.34s41.67-95.83 125-95.83c75 0 116.67 25 137.5 87.5 4.17 12.5 16.67 20.83 29.17 20.83h66.66c16.67 0 29.17-12.5 29.17-29.16v-4.17c-16.67-91.67-91.67-162.5-187.5-170.83v-100c0-16.67-12.5-29.17-33.33-33.34h-62.5c-16.67 0-29.17 12.5-33.34 33.34v95.83c-125 16.67-204.16 100-204.16 204.17 0 137.5 83.33 191.66 258.33 212.5 116.67 20.83 154.17 45.83 154.17 112.5s-58.34 112.5-137.5 112.5c-108.34 0-145.84-45.84-158.34-108.34-4.16-16.66-16.66-25-29.16-25h-70.84c-16.66 0-29.16 12.5-29.16 29.17v4.17c16.66 104.16 83.33 179.16 220.83 200v100c0 16.66 12.5 29.16 33.33 33.33h62.5c16.67 0 29.17-12.5 33.34-33.33v-100c125-20.84 208.33-108.34 208.33-220.84z" fill="#fff"/>
<path d="M787.5 1595.83c-325-116.66-491.67-479.16-370.83-800 62.5-175 200-308.33 370.83-370.83 16.67-8.33 25-20.83 25-41.67V325c0-16.67-8.33-29.17-25-33.33-4.17 0-12.5 0-16.67 4.16-395.83 125-612.5 545.84-487.5 941.67 75 233.33 254.17 412.5 487.5 487.5 16.67 8.33 33.34 0 37.5-16.67 4.17-4.16 4.17-8.33 4.17-16.66v-58.34c0-12.5-12.5-29.16-25-37.5zM1229.17 295.83c-16.67-8.33-33.34 0-37.5 16.67-4.17 4.17-4.17 8.33-4.17 16.67v58.33c0 16.67 12.5 33.33 25 41.67 325 116.66 491.67 479.16 370.83 800-62.5 175-200 308.33-370.83 370.83-16.67 8.33-25 20.83-25 41.67V1700c0 16.67 8.33 29.17 25 33.33 4.17 0 12.5 0 16.67-4.16 395.83-125 612.5-545.84 487.5-941.67-75-237.5-258.34-416.67-487.5-491.67z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/payto_logos/venmo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

38
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -15,6 +15,8 @@ import Lightbox from 'yet-another-react-lightbox' @@ -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({ @@ -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 <a href="payto://..."> and plain payto:// with placeholder
htmlString = htmlString.replace(/<a[^>]*href=["'](payto:\/\/[^"']+)["'][^>]*>([^<]*)<\/a>/gi, (_match, paytoUri, _linkText) => {
const parsed = parsePaytoUri(paytoUri)
if (!parsed) return _match
const escaped = paytoUri.replace(/"/g, '&quot;').replace(/&/g, '&amp;').replace(/'/g, '&#39;')
return `<span data-payto-uri="${escaped}" class="payto-placeholder"></span>`
})
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, '&quot;').replace(/&/g, '&amp;').replace(/'/g, '&#39;')
return `${prefix}<span data-payto-uri="${escaped}" class="payto-placeholder"></span>`
})
// 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({ @@ -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(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/&#39;/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(<PaytoLink paytoUri={decoded} className="text-primary hover:underline break-words" />)
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

74
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -19,6 +19,8 @@ import Zoom from 'yet-another-react-lightbox/plugins/zoom' @@ -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<st @@ -2865,9 +2867,9 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st
const hashtagMatches = Array.from(text.matchAll(hashtagRegex))
hashtagMatches.forEach(match => {
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<st @@ -2920,7 +2922,7 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st
if (isProfileType) {
// Skip if already in code, bold, italic, strikethrough, link, hashtag, or relay-url
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
)
@ -2935,6 +2937,29 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st @@ -2935,6 +2937,29 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st
}
}
})
// payto: URIs (RFC-8905 / NIP-A3) – process after nostr so we don't match inside other patterns
const paytoMatches = Array.from(text.matchAll(PAYTO_URI_REGEX))
paytoMatches.forEach(match => {
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<st @@ -2983,21 +3008,27 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st
/>
)
} 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(
<a
key={`${keyPrefix}-link-${i}`}
href={url}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
target="_blank"
rel="noopener noreferrer"
>
{linkContent}
</a>
)
if (url.startsWith('payto://')) {
parts.push(
<PaytoLink key={`${keyPrefix}-payto-link-${i}`} paytoUri={url} className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words">
{parseInlineMarkdown(text, `${keyPrefix}-link-${i}`, _footnotes)}
</PaytoLink>
)
} else {
const linkContent = parseInlineMarkdown(text, `${keyPrefix}-link-${i}`, _footnotes)
parts.push(
<a
key={`${keyPrefix}-link-${i}`}
href={url}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
target="_blank"
rel="noopener noreferrer"
>
{linkContent}
</a>
)
}
} 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: Map<st @@ -3040,6 +3071,15 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st
// Fallback for unexpected types (shouldn't happen, but handle gracefully)
parts.push(<span key={`${keyPrefix}-nostr-${i}`}>nostr:{bech32Id}</span>)
}
} else if (pattern.type === 'payto') {
const payto = pattern.data as { type: string; authority: string; raw: string }
parts.push(
<PaytoLink
key={`${keyPrefix}-payto-${i}`}
paytoUri={payto.raw}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
/>
)
}
lastIndex = pattern.end

81
src/components/PaytoDialog/index.tsx

@ -0,0 +1,81 @@ @@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{isLightning && <Zap className="size-5 text-yellow-400" />}
<span>{label}</span>
</DialogTitle>
<DialogDescription>
{isLightning
? t('Lightning payment address – copy to pay via your wallet')
: t('Payment address – copy to use in your wallet or app')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all select-text">
{authority}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => handleCopy(authority, label)}
className="gap-2"
>
<Copy className="size-4" />
{t('Copy address')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleCopy(paytoUri)}
className="gap-2"
>
<Copy className="size-4" />
{t('Copy payto URI')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

137
src/components/PaytoLink/index.tsx

@ -0,0 +1,137 @@ @@ -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 ? <span className={className}>{children}</span> : 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 ?? <span className="break-all">{authority}</span>
const iconEl = (
<span className="shrink-0 flex items-center justify-center w-4 h-4 text-[1rem] leading-none" aria-hidden>
{logoPath ? (
<img src={logoPath} alt="" className="size-4 object-contain" />
) : iconChar != null ? (
<span className={cn(
'inline-flex items-center justify-center',
isLightning && 'text-yellow-400'
)}>
{iconChar}
</span>
) : (
<HelpCircle className="size-3.5 text-muted-foreground" />
)}
</span>
)
if (profileUrl) {
return (
<a
href={profileUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
'text-primary hover:underline cursor-pointer text-left break-words inline-flex items-center gap-1.5',
className
)}
title={`${displayLabel}: ${t('Open on website')}`}
onClick={(e) => e.stopPropagation()}
>
{iconEl}
{content}
</a>
)
}
return (
<>
<button
type="button"
onClick={handleClick}
className={cn(
'text-primary hover:underline cursor-pointer text-left break-words inline-flex items-center gap-1.5',
className
)}
title={known ? `${displayLabel}: ${t('Click to open payment options')}` : t('Click to copy address')}
>
{iconEl}
{content}
</button>
{known && (
<PaytoDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
type={type}
authority={authority}
paytoUri={raw}
/>
)}
</>
)
}

109
src/components/Profile/index.tsx

@ -32,9 +32,10 @@ import { toNoteList } from '@/lib/link' @@ -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' @@ -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 }) { @@ -129,10 +131,13 @@ export default function Profile({ id }: { id?: string }) {
const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | 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 }) { @@ -476,61 +481,53 @@ export default function Profile({ id }: { id?: string }) {
))}
</div>
)}
{/* 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 && (
<div className="mt-2 p-2 border rounded-lg bg-muted/50 min-w-0 overflow-hidden">
<div className="text-xs font-semibold text-muted-foreground mb-2">Payment Methods</div>
<div className="space-y-2 min-w-0">
{mergedPaymentMethods.map((method, idx) => {
const authority = method.authority
const paytoUri = method.payto
const isLightning = method.type === 'lightning'
return (
<div key={idx} className="text-sm min-w-0">
<div className="font-medium">{method.displayType}</div>
{authority && (
<div className="text-muted-foreground mt-1 flex items-center gap-2 min-w-0">
{isLightning && <Zap className="size-3 text-yellow-400 shrink-0" />}
{isLightning && pubkey ? (
<button
type="button"
className="text-left hover:underline break-all min-w-0 text-primary"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpenZapDialog(true)
}}
>
{authority}
</button>
) : paytoUri ? (
<a
href={paytoUri}
target="_blank"
rel="noopener noreferrer"
className="hover:underline break-all min-w-0 text-primary"
onClick={(e) => e.stopPropagation()}
>
{authority}
</a>
) : (
<span className="select-text min-w-0 break-all">{authority}</span>
)}
</div>
)}
{(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && (
<div className="text-muted-foreground text-xs mt-1">
{method.currency && <span>({method.currency})</span>}
{method.minAmount !== undefined && method.maxAmount !== undefined && (
<span className="ml-2">
{method.minAmount}-{method.maxAmount}
</span>
)}
</div>
)}
</div>
)
})}
{mergedPaymentMethods.map((method, idx) => (
<div key={idx} className="text-sm min-w-0">
<div className="font-medium">{method.displayType}</div>
{method.authority && (
<div className="text-muted-foreground mt-1 flex items-center gap-1 min-w-0">
<PaytoLink
type={method.type}
authority={method.authority}
paytoUri={method.payto}
pubkey={method.type === 'lightning' ? pubkey : undefined}
onOpenZap={method.type === 'lightning' ? () => setOpenZapDialog(true) : undefined}
className="hover:underline break-all min-w-0 text-primary flex-1"
>
{method.authority}
</PaytoLink>
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
navigator.clipboard.writeText(method.authority)
toast.success(t('Copied to clipboard'))
}}
className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted"
title={t('Copy address')}
>
<Copy className="size-3.5" />
</button>
</div>
)}
{(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && (
<div className="text-muted-foreground text-xs mt-1">
{method.currency && <span>({method.currency})</span>}
{method.minAmount !== undefined && method.maxAmount !== undefined && (
<span className="ml-2">
{method.minAmount}-{method.maxAmount}
</span>
)}
</div>
)}
</div>
))}
</div>
</div>
)}

2
src/components/ProfileZapButton/index.tsx

@ -28,7 +28,7 @@ export default function ProfileZapButton({ @@ -28,7 +28,7 @@ export default function ProfileZapButton({
>
<Zap className="text-yellow-400" />
</Button>
{!setOpenZapDialog && <ZapDialog open={open} setOpen={setOpen} pubkey={pubkey} />}
{!setOpenZapDialog && <ZapDialog open={open} setOpen={setInternalOpen} pubkey={pubkey} />}
</>
)
}

10
src/i18n/locales/en.ts

@ -66,6 +66,16 @@ export default { @@ -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',

47
src/lib/nostr-parser.tsx

@ -9,13 +9,15 @@ import WebPreview from '@/components/WebPreview' @@ -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 { @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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? @@ -586,6 +617,16 @@ export function renderNostrContent(parsedContent: ParsedNostrContent, className?
)
}
if (element.type === 'payto' && element.paytoUri) {
return (
<PaytoLink
key={index}
paytoUri={element.paytoUri}
className="text-primary hover:underline break-words"
/>
)
}
if (element.type === 'nostr' && element.bech32Id && element.nostrType) {
// Render as embedded content
if (element.nostrType === 'npub' || element.nostrType === 'nprofile') {

155
src/lib/payto.ts

@ -0,0 +1,155 @@ @@ -0,0 +1,155 @@
/**
* payto: URI handling (RFC-8905 / NIP-A3)
* Parse and normalize payto://<type>/<authority> 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<string, string> = {
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<string, string> = {
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'
}

2
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -9,7 +9,7 @@ import { Slider } from '@/components/ui/slider' @@ -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'

Loading…
Cancel
Save