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.
This commit is contained in:
Claude
2026-06-09 20:58:58 +00:00
parent ef7658ae09
commit 62e522ccc4
3 changed files with 75 additions and 0 deletions
@@ -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)
@@ -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,
@@ -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<ClinkOfferSegment>()
.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 })
}
}