From 62e522ccc433b05b875d79856ae7333abe6d0d42 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:58:58 +0000 Subject: [PATCH] feat(clink): detect noffer pointers in rich-text as ClinkOfferSegment Teaches the commons RichTextParser to recognize an inline noffer1... token and emit a ClinkOfferSegment carrying the decoded NOffer, so a GUI front end can render a 'Pay' card in the note body (the feed-offer feature). Bare tokens only for now; nostr:/lightning: prefixed forms fall through. Covered by ClinkOfferSegmentTest on JVM. --- .../commons/richtext/RichTextParser.kt | 6 ++ .../richtext/RichTextParserSegments.kt | 7 +++ .../commons/richtext/ClinkOfferSegmentTest.kt | 62 +++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 959b8f4a31..98cd7c3129 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.util.isValidUrl +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip31Alts.AltTag @@ -361,6 +363,10 @@ class RichTextParser { if (word.startsWith("cashuA", true) || word.startsWith("cashuB", true)) return CashuSegment(word) + if (word.startsWith("noffer1", true)) { + (ClinkPointerParser.parse(word) as? NOffer)?.let { return ClinkOfferSegment(word, it) } + } + if (word.startsWith('#')) return parseHash(word, tags) if (EmojiCoder.isCoded(word)) return SecretEmoji(word) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 25d3eae75d..0c5e7c062a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -92,6 +93,12 @@ class CashuSegment( segment: String, ) : Segment(segment) +@Immutable +class ClinkOfferSegment( + segment: String, + val offer: NOffer, +) : Segment(segment) + @Immutable class EmailSegment( segment: String, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt new file mode 100644 index 0000000000..9925906bd8 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ClinkOfferSegmentTest { + // generated by @shocknet/clink-sdk@1.5.5 (see quartz ClinkInteropTest) + private val offerFixed = + "noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c" + + private fun words(text: String) = + RichTextParser() + .parseText(text, EmptyTagList, null) + .paragraphs + .flatMap { it.words } + + @Test + fun detectsNofferInlineAsClinkOfferSegment() { + val segment = + words("Pay me here $offerFixed thanks") + .filterIsInstance() + .single() + + assertEquals(offerFixed, segment.segmentText) + assertEquals("offer-id", segment.offer.pointer) + assertEquals(OfferPriceType.FIXED, segment.offer.priceType) + assertEquals(21000, segment.offer.price) + } + + @Test + fun malformedNofferIsNotDetected() { + assertTrue(words("nope noffer1notvalidbech32 end").none { it is ClinkOfferSegment }) + } + + @Test + fun plainTextHasNoOfferSegment() { + assertTrue(words("just a normal sentence").none { it is ClinkOfferSegment }) + } +}