feat(clink): pay CLINK noffer offers from notes, profiles & wallet (port wisp PRs)
- NIP-69-style noffer parsing (Noffer.kt) + encrypted RPC client (NofferClient) for fetching invoices from CLINK services - Full-height NofferPaySheet with payee recognition and amount entry - noffer1 strings in notes, profiles, QR sheet and drawer resolve to a pay action; onPayInvoice plumbed through DM, group & search notes - Offer field on profile edit; payee re-parse on profile cache update - NofferClient routes through the relay client provider (Tor-aware)
This commit is contained in:
@@ -1245,6 +1245,7 @@ fun WispNavHost(
|
||||
SearchScreen(
|
||||
viewModel = searchViewModel,
|
||||
relayPool = feedViewModel.relayPool,
|
||||
onPayInvoice = { bolt11 -> feedViewModel.payInvoice(bolt11) },
|
||||
eventRepo = feedViewModel.eventRepo,
|
||||
muteRepo = feedViewModel.muteRepo,
|
||||
contactRepo = feedViewModel.contactRepo,
|
||||
@@ -1403,6 +1404,7 @@ fun WispNavHost(
|
||||
noteActions = remember {
|
||||
com.darkwisp.app.ui.component.NoteActions(
|
||||
nip05Repo = feedViewModel.nip05Repo,
|
||||
onPayInvoice = { bolt11 -> feedViewModel.payInvoice(bolt11) },
|
||||
onAddEmojiSet = { pk, dTag -> feedViewModel.addSetToEmojiList(pk, dTag) },
|
||||
onRemoveEmojiSet = { pk, dTag -> feedViewModel.removeSetFromEmojiList(pk, dTag) },
|
||||
isEmojiSetAdded = { pk, dTag ->
|
||||
@@ -1491,6 +1493,7 @@ fun WispNavHost(
|
||||
noteActions = remember {
|
||||
com.darkwisp.app.ui.component.NoteActions(
|
||||
nip05Repo = feedViewModel.nip05Repo,
|
||||
onPayInvoice = { bolt11 -> feedViewModel.payInvoice(bolt11) },
|
||||
onAddEmojiSet = { pk, dTag -> feedViewModel.addSetToEmojiList(pk, dTag) },
|
||||
onRemoveEmojiSet = { pk, dTag -> feedViewModel.removeSetFromEmojiList(pk, dTag) },
|
||||
isEmojiSetAdded = { pk, dTag ->
|
||||
@@ -1729,6 +1732,7 @@ fun WispNavHost(
|
||||
noteActions = remember {
|
||||
com.darkwisp.app.ui.component.NoteActions(
|
||||
nip05Repo = feedViewModel.nip05Repo,
|
||||
onPayInvoice = { bolt11 -> feedViewModel.payInvoice(bolt11) },
|
||||
onNoteClick = { eventId -> navController.navigate("thread/$eventId") },
|
||||
onAddEmojiSet = { pk, dTag -> feedViewModel.addSetToEmojiList(pk, dTag) },
|
||||
onRemoveEmojiSet = { pk, dTag -> feedViewModel.removeSetFromEmojiList(pk, dTag) },
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.darkwisp.app.nostr
|
||||
|
||||
/**
|
||||
* CLINK Offers (`noffer1…`) — a Nostr-native successor to LNURL-pay.
|
||||
*
|
||||
* Spec: https://github.com/shocknet/CLINK/blob/main/specs/clink-offers.md
|
||||
*
|
||||
* A `noffer1…` bech32 string carries TLVs describing a static payment offer:
|
||||
*
|
||||
* TLV 0 — 32-byte service pubkey (hex)
|
||||
* TLV 1 — recommended relay URL (utf-8) where the service listens
|
||||
* TLV 2 — opaque offer identifier (utf-8)
|
||||
* TLV 3 — (opt) pricing type: 0=Fixed, 1=Variable, 2=Spontaneous (default)
|
||||
* TLV 4 — (opt) price in sats (big-endian integer)
|
||||
* TLV 5 — (opt) currency code (utf-8; only meaningful with Variable)
|
||||
*
|
||||
* The payer NIP-44 encrypts a kind-21001 request to the service pubkey and the
|
||||
* service replies with an encrypted kind-21001 carrying a bolt11 invoice —
|
||||
* see `NofferClient`.
|
||||
*/
|
||||
enum class NofferPricing {
|
||||
FIXED,
|
||||
VARIABLE,
|
||||
SPONTANEOUS;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* TLV 3 byte → pricing type. Matches the CLINK spec and the zap.cooking
|
||||
* reference decoder: 0=Fixed, 1=Variable, anything else (incl. 2) =
|
||||
* Spontaneous. Absent TLV 3 also defaults to Spontaneous.
|
||||
*/
|
||||
fun fromByte(byte: Int): NofferPricing = when (byte) {
|
||||
0 -> FIXED
|
||||
1 -> VARIABLE
|
||||
else -> SPONTANEOUS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NofferData(
|
||||
/** 32-byte service pubkey, hex-encoded lowercase. (TLV 0) */
|
||||
val pubkey: String,
|
||||
/** Recommended relay URL where the service listens. (TLV 1) */
|
||||
val relay: String,
|
||||
/** Opaque offer identifier the service uses to look up the offer. (TLV 2) */
|
||||
val offerId: String,
|
||||
/** Pricing type — defaults to SPONTANEOUS when TLV 3 is absent. */
|
||||
val pricing: NofferPricing,
|
||||
/** Price in sats. (TLV 4) Present for Fixed offers and as a hint for Variable. */
|
||||
val price: Long?,
|
||||
/** Currency code (TLV 5) — only meaningful when pricing == VARIABLE. */
|
||||
val currency: String?,
|
||||
/**
|
||||
* The bare `noffer1…` string (no `nostr:` prefix) this was decoded from.
|
||||
* Kept so callers can re-display / QR-encode the offer verbatim — the spec
|
||||
* requires QR payloads to be exactly the bech32 string.
|
||||
*/
|
||||
val raw: String
|
||||
) {
|
||||
/**
|
||||
* True when the payer must (or may) supply an amount: Spontaneous always
|
||||
* needs one; Variable lets the payer hint at one (the service decides);
|
||||
* Fixed bakes the amount into the offer.
|
||||
*/
|
||||
val acceptsAmount: Boolean
|
||||
get() = pricing != NofferPricing.FIXED
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed failure surfaced by the CLINK service (or the client on timeout /
|
||||
* transport failure). `code` follows the spec's error table; `code == 0`
|
||||
* marks a local/transport failure with no service code.
|
||||
*/
|
||||
class NofferException(
|
||||
val code: Int,
|
||||
message: String,
|
||||
val rangeMin: Long? = null,
|
||||
val rangeMax: Long? = null,
|
||||
val latest: String? = null
|
||||
) : Exception(message)
|
||||
|
||||
object Noffer {
|
||||
|
||||
private val nofferRegex = Regex(
|
||||
"""^(nostr:)?noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$""",
|
||||
RegexOption.IGNORE_CASE
|
||||
)
|
||||
|
||||
/**
|
||||
* Lightweight detector — checks shape only, does not decode TLVs. Use this
|
||||
* in segment / paste parsers where you just need "is this noffer-shaped";
|
||||
* call [decode] for real use.
|
||||
*/
|
||||
fun isNofferString(s: String?): Boolean {
|
||||
if (s == null) return false
|
||||
val trimmed = s.trim()
|
||||
if (trimmed.isEmpty()) return false
|
||||
return nofferRegex.matches(trimmed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leading `nostr:` prefix (and surrounding whitespace) and return
|
||||
* the bare `noffer1…` token. Use before building a QR payload — the spec
|
||||
* requires the QR to be exactly the bech32 string with no scheme prefix.
|
||||
*/
|
||||
fun stripNostrPrefix(noffer: String): String {
|
||||
val trimmed = noffer.trim()
|
||||
return if (trimmed.startsWith("nostr:", ignoreCase = true)) {
|
||||
trimmed.substring("nostr:".length)
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a `noffer1…` (or `nostr:noffer1…`) string into its TLV fields.
|
||||
* Throws on wrong HRP, non-bech32 input, truncated TLVs, or a missing /
|
||||
* wrong-length required TLV (0/1/2).
|
||||
*/
|
||||
fun decode(input: String): NofferData {
|
||||
val cleaned = stripNostrPrefix(input)
|
||||
require(cleaned.lowercase().startsWith("noffer1")) { "Not a noffer string" }
|
||||
val (hrp, data) = Nip19.bech32Decode(cleaned)
|
||||
require(hrp == "noffer") { "Expected noffer, got $hrp" }
|
||||
|
||||
val tlvs = parseTlvs(data)
|
||||
|
||||
val pubkeyTlv = tlvs.firstOrNull { it.first == 0 }
|
||||
require(pubkeyTlv != null && pubkeyTlv.second.size == 32) { "noffer missing service pubkey" }
|
||||
val relay = tlvs.firstOrNull { it.first == 1 }?.second?.toString(Charsets.UTF_8)
|
||||
require(!relay.isNullOrEmpty()) { "noffer missing relay" }
|
||||
val offerTlv = tlvs.firstOrNull { it.first == 2 }
|
||||
require(offerTlv != null) { "noffer missing offer id" }
|
||||
val offerId = offerTlv.second.toString(Charsets.UTF_8)
|
||||
|
||||
val pricing = tlvs.firstOrNull { it.first == 3 }?.second?.firstOrNull()
|
||||
?.let { NofferPricing.fromByte(it.toInt() and 0xFF) }
|
||||
?: NofferPricing.SPONTANEOUS
|
||||
|
||||
val price = tlvs.firstOrNull { it.first == 4 }?.second
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { bigEndianLong(it) }
|
||||
|
||||
val currency = tlvs.firstOrNull { it.first == 5 }?.second
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
return NofferData(
|
||||
pubkey = pubkeyTlv.second.toHex(),
|
||||
relay = relay,
|
||||
offerId = offerId,
|
||||
pricing = pricing,
|
||||
price = price,
|
||||
currency = currency,
|
||||
raw = cleaned
|
||||
)
|
||||
}
|
||||
|
||||
/** Decode tolerantly, returning null instead of throwing. */
|
||||
fun decodeOrNull(input: String): NofferData? = try {
|
||||
decode(input)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// --- TLV parsing ---
|
||||
|
||||
private fun parseTlvs(bytes: ByteArray): List<Pair<Int, ByteArray>> {
|
||||
val tlvs = mutableListOf<Pair<Int, ByteArray>>()
|
||||
var i = 0
|
||||
while (i < bytes.size) {
|
||||
require(i + 2 <= bytes.size) { "Truncated TLV" }
|
||||
val type = bytes[i].toInt() and 0xFF
|
||||
val length = bytes[i + 1].toInt() and 0xFF
|
||||
require(i + 2 + length <= bytes.size) { "Truncated TLV value" }
|
||||
tlvs.add(type to bytes.copyOfRange(i + 2, i + 2 + length))
|
||||
i += 2 + length
|
||||
}
|
||||
return tlvs
|
||||
}
|
||||
|
||||
private fun bigEndianLong(bytes: ByteArray): Long {
|
||||
var n = 0L
|
||||
for (b in bytes) n = n * 256 + (b.toInt() and 0xFF)
|
||||
return n
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@ data class ProfileData(
|
||||
val banner: String?,
|
||||
val nip05: String?,
|
||||
val lud16: String?,
|
||||
/**
|
||||
* CLINK offer (`noffer1…`) advertised in kind-0. Read tolerantly from
|
||||
* `clink_offer`, `noffer`, or `offer` for cross-client compatibility.
|
||||
* Spec: https://github.com/shocknet/CLINK/blob/main/specs/clink-offers.md
|
||||
*/
|
||||
val clinkOffer: String? = null,
|
||||
val updatedAt: Long
|
||||
) {
|
||||
val displayString: String
|
||||
@@ -40,6 +46,11 @@ data class ProfileData(
|
||||
banner = obj["banner"]?.jsonPrimitive?.content,
|
||||
nip05 = obj["nip05"]?.jsonPrimitive?.content,
|
||||
lud16 = obj["lud16"]?.jsonPrimitive?.content,
|
||||
clinkOffer = (
|
||||
obj["clink_offer"]?.jsonPrimitive?.content
|
||||
?: obj["noffer"]?.jsonPrimitive?.content
|
||||
?: obj["offer"]?.jsonPrimitive?.content
|
||||
).takeUnlessBlank(),
|
||||
updatedAt = event.created_at
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.darkwisp.app.repo
|
||||
|
||||
import com.darkwisp.app.nostr.ClientMessage
|
||||
import com.darkwisp.app.nostr.Filter
|
||||
import com.darkwisp.app.nostr.Keys
|
||||
import com.darkwisp.app.nostr.Nip44
|
||||
import com.darkwisp.app.nostr.NofferData
|
||||
import com.darkwisp.app.nostr.NofferException
|
||||
import com.darkwisp.app.nostr.Noffer
|
||||
import com.darkwisp.app.nostr.NostrEvent
|
||||
import com.darkwisp.app.nostr.RelayMessage
|
||||
import com.darkwisp.app.nostr.hexToByteArray
|
||||
import com.darkwisp.app.nostr.toHex
|
||||
import com.darkwisp.app.relay.Relay
|
||||
import com.darkwisp.app.relay.RelayConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* CLINK noffer kind-21001 RPC client (payer side).
|
||||
*
|
||||
* Spec: https://github.com/shocknet/CLINK/blob/main/specs/clink-offers.md
|
||||
*
|
||||
* Flow:
|
||||
* 1. Build a JSON request payload with the offer id and (when needed) the
|
||||
* amount in sats.
|
||||
* 2. NIP-44 encrypt the payload to the offer's service pubkey.
|
||||
* 3. Sign + publish a kind-21001 event tagged `["p", servicePubkey]` and
|
||||
* `["clink_version","1"]` on the relay carried in the noffer's TLV 1.
|
||||
* 4. Subscribe to kind-21001 on the same relay, filtered to events from the
|
||||
* service tagged for us. Decrypt the first response and parse it as either
|
||||
* `{bolt11}` (success) or `{error,code,…}` (typed failure → [NofferException]).
|
||||
*
|
||||
* The caller pays the returned bolt11 via the active wallet provider.
|
||||
*/
|
||||
object NofferClient {
|
||||
|
||||
private const val KIND_NOFFER_RPC = 21001
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Request a bolt11 invoice for [noffer], following a `code: 3`
|
||||
* "expired/moved" response to its `latest` offer once (per spec).
|
||||
* Throws [NofferException] on failure or timeout.
|
||||
*/
|
||||
suspend fun requestInvoice(
|
||||
noffer: NofferData,
|
||||
keypair: Keys.Keypair,
|
||||
amountSats: Long?,
|
||||
description: String? = null,
|
||||
zapRequest: String? = null,
|
||||
timeoutMs: Long = 30_000,
|
||||
allowRetry: Boolean = true
|
||||
): String {
|
||||
return try {
|
||||
requestInvoiceOnce(noffer, keypair, amountSats, description, zapRequest, timeoutMs)
|
||||
} catch (err: NofferException) {
|
||||
// Expired or moved — if the service handed us a replacement, decode
|
||||
// it and retry exactly once against the new offer.
|
||||
if (allowRetry && err.code == 3) {
|
||||
val updated = err.latest?.let { Noffer.decodeOrNull(it) } ?: throw err
|
||||
requestInvoice(
|
||||
noffer = updated, keypair = keypair, amountSats = amountSats,
|
||||
description = description, zapRequest = zapRequest,
|
||||
timeoutMs = timeoutMs, allowRetry = false
|
||||
)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestInvoiceOnce(
|
||||
noffer: NofferData,
|
||||
keypair: Keys.Keypair,
|
||||
amountSats: Long?,
|
||||
description: String?,
|
||||
zapRequest: String?,
|
||||
timeoutMs: Long
|
||||
): String {
|
||||
if (keypair.privkey.size != 32) {
|
||||
throw NofferException(0, "Sign in with a key that can sign to pay an offer.")
|
||||
}
|
||||
val servicePubkey = noffer.pubkey
|
||||
val peer = try {
|
||||
servicePubkey.hexToByteArray()
|
||||
} catch (_: Exception) {
|
||||
throw NofferException(0, "Invalid offer service pubkey.")
|
||||
}
|
||||
val convKey = try {
|
||||
Nip44.getConversationKey(keypair.privkey, peer)
|
||||
} catch (_: Exception) {
|
||||
throw NofferException(0, "Could not derive an encryption key for the offer.")
|
||||
}
|
||||
|
||||
// Build the encrypted request payload. amount_sats is required for
|
||||
// Spontaneous/Variable offers; harmless to include for Fixed.
|
||||
val payload = buildJsonObject {
|
||||
put("offer", JsonPrimitive(noffer.offerId))
|
||||
if (amountSats != null && amountSats > 0) put("amount_sats", JsonPrimitive(amountSats))
|
||||
if (!description.isNullOrEmpty()) put("description", JsonPrimitive(description.take(100)))
|
||||
if (!zapRequest.isNullOrEmpty()) put("zap", JsonPrimitive(zapRequest))
|
||||
}.toString()
|
||||
val ciphertext = Nip44.encrypt(payload, convKey)
|
||||
|
||||
val event = NostrEvent.create(
|
||||
privkey = keypair.privkey,
|
||||
pubkey = keypair.pubkey,
|
||||
kind = KIND_NOFFER_RPC,
|
||||
content = ciphertext,
|
||||
tags = listOf(listOf("p", servicePubkey), listOf("clink_version", "1"))
|
||||
)
|
||||
|
||||
val subId = "noffer-${UUID.randomUUID().toString().take(8)}"
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val relay = Relay(RelayConfig(noffer.relay), { Relay.createClient() }, scope = scope)
|
||||
try {
|
||||
relay.connect()
|
||||
return withTimeout(timeoutMs) {
|
||||
relay.connectionState.first { it }
|
||||
|
||||
// Subscribe BEFORE publishing so a fast response isn't raced
|
||||
// away. 5s grace on `since` covers slight clock skew with the
|
||||
// service.
|
||||
val filter = Filter(
|
||||
kinds = listOf(KIND_NOFFER_RPC),
|
||||
authors = listOf(servicePubkey),
|
||||
pTags = listOf(keypair.pubkey.toHex()),
|
||||
since = System.currentTimeMillis() / 1000 - 5
|
||||
)
|
||||
relay.send(ClientMessage.req(subId, filter))
|
||||
relay.send(ClientMessage.event(event))
|
||||
|
||||
var result: String? = null
|
||||
relay.messages.first { msg ->
|
||||
if (msg !is RelayMessage.EventMsg) return@first false
|
||||
if (msg.subscriptionId != subId) return@first false
|
||||
val response = msg.event
|
||||
// `authors` already guards this server-side; defend against
|
||||
// relays that ignore filters.
|
||||
if (response.pubkey != servicePubkey) return@first false
|
||||
val plaintext = try {
|
||||
Nip44.decrypt(response.content, convKey)
|
||||
} catch (_: Exception) {
|
||||
return@first false
|
||||
}
|
||||
val parsed = parseResponse(plaintext) ?: return@first false
|
||||
when {
|
||||
!parsed.bolt11.isNullOrEmpty() -> {
|
||||
result = parsed.bolt11
|
||||
true
|
||||
}
|
||||
parsed.code != null || parsed.error != null -> {
|
||||
throw NofferException(
|
||||
code = parsed.code ?: 0,
|
||||
message = parsed.error ?: "The offer request failed.",
|
||||
rangeMin = parsed.rangeMin,
|
||||
rangeMax = parsed.rangeMax,
|
||||
latest = parsed.latest
|
||||
)
|
||||
}
|
||||
// Unrecognised payload — keep listening; maybe a stale event.
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
result ?: throw NofferException(0, "The offer request failed.")
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw NofferException(0, "The offer request timed out. The recipient's service may be offline.")
|
||||
} finally {
|
||||
relay.send(ClientMessage.close(subId))
|
||||
relay.disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private data class Response(
|
||||
val bolt11: String?,
|
||||
val error: String?,
|
||||
val code: Int?,
|
||||
val rangeMin: Long?,
|
||||
val rangeMax: Long?,
|
||||
val latest: String?
|
||||
)
|
||||
|
||||
private fun parseResponse(plaintext: String): Response? {
|
||||
return try {
|
||||
val obj = json.parseToJsonElement(plaintext).jsonObject
|
||||
val range = obj["range"]?.jsonObject
|
||||
Response(
|
||||
bolt11 = obj["bolt11"]?.jsonPrimitive?.content?.trim(),
|
||||
error = obj["error"]?.jsonPrimitive?.content,
|
||||
code = obj["code"]?.jsonPrimitive?.content?.toDoubleOrNull()?.toInt(),
|
||||
rangeMin = range?.get("min")?.jsonPrimitive?.content?.toLongOrNull(),
|
||||
rangeMax = range?.get("max")?.jsonPrimitive?.content?.toLongOrNull(),
|
||||
latest = obj["latest"]?.jsonPrimitive?.content
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ProfileRepository(context: Context) {
|
||||
private companion object {
|
||||
/** Bump when ProfileData gains fields that need a cache re-parse. */
|
||||
const val SCHEMA_VERSION = 1
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("wisp_profiles", Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@@ -23,9 +28,24 @@ class ProfileRepository(context: Context) {
|
||||
val avatarDir = File(context.filesDir, "avatars").also { it.mkdirs() }
|
||||
|
||||
init {
|
||||
migrateSchema()
|
||||
loadFromPrefs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached ProfileData is parsed JSON, so profiles stored before a new
|
||||
* field existed (e.g. clinkOffer) deserialize without it — and the
|
||||
* timestamp guard in [updateFromEvent] blocks re-parsing an unchanged
|
||||
* kind-0. On schema bump, drop the stored timestamps (keeping the
|
||||
* profiles) so the next received event re-parses with the new fields.
|
||||
*/
|
||||
private fun migrateSchema() {
|
||||
if (prefs.getInt("schema_version", 0) >= SCHEMA_VERSION) return
|
||||
val editor = prefs.edit()
|
||||
prefs.all.keys.filter { it.startsWith("p_ts_") }.forEach { editor.remove(it) }
|
||||
editor.putInt("schema_version", SCHEMA_VERSION).apply()
|
||||
}
|
||||
|
||||
fun updateFromEvent(event: NostrEvent): ProfileData? {
|
||||
if (event.kind != 0) return null
|
||||
val existing = timestamps.get(event.pubkey)
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
package com.darkwisp.app.ui.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
import androidx.compose.material.icons.outlined.CurrencyBitcoin
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.darkwisp.app.R
|
||||
import com.darkwisp.app.nostr.Bolt11
|
||||
import com.darkwisp.app.nostr.NofferData
|
||||
import com.darkwisp.app.nostr.NofferException
|
||||
import com.darkwisp.app.nostr.NofferPricing
|
||||
import com.darkwisp.app.nostr.ProfileData
|
||||
import com.darkwisp.app.nostr.toNpub
|
||||
import com.darkwisp.app.repo.EventRepository
|
||||
import com.darkwisp.app.repo.KeyRepository
|
||||
import com.darkwisp.app.repo.NofferClient
|
||||
import com.darkwisp.app.repo.ZapSender
|
||||
import com.darkwisp.app.ui.theme.WispThemeColors
|
||||
import com.darkwisp.app.ui.util.AmountFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private fun shortNpub(pubkeyHex: String): String =
|
||||
pubkeyHex.toNpub().let { "${it.take(12)}...${it.takeLast(4)}" }
|
||||
|
||||
private fun priceLabel(price: Long, currency: String?): String =
|
||||
if (!currency.isNullOrEmpty()) "${AmountFormatter.formatSatsOnly(price)} $currency"
|
||||
else "${AmountFormatter.formatSatsOnly(price)} sats"
|
||||
|
||||
/**
|
||||
* Inline "Pay offer" card rendered for a CLINK `noffer1…` found in a note body.
|
||||
* Tapping opens [NofferPaySheet]. Resolves the recipient's name itself so it
|
||||
* reads consistently across feed and thread — the offer recipient is usually
|
||||
* not the note author, so callers' profile maps often haven't fetched it.
|
||||
*/
|
||||
@Composable
|
||||
fun NofferCard(
|
||||
noffer: NofferData,
|
||||
eventRepo: EventRepository? = null,
|
||||
onPayInvoice: (suspend (String) -> Boolean)? = null
|
||||
) {
|
||||
val zapColor = WispThemeColors.zapColor
|
||||
var showSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val profileVersion = eventRepo?.profileVersion?.collectAsState()
|
||||
val profile = remember(noffer.pubkey, profileVersion?.value) {
|
||||
eventRepo?.getProfileData(noffer.pubkey)
|
||||
}
|
||||
LaunchedEffect(noffer.pubkey) {
|
||||
eventRepo?.requestProfileIfMissing(noffer.pubkey)
|
||||
}
|
||||
val recipientName = profile?.displayString ?: shortNpub(noffer.pubkey)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = zapColor.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, zapColor.copy(alpha = 0.25f)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable { showSheet = true }
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.background(zapColor.copy(alpha = 0.15f), CircleShape)
|
||||
) {
|
||||
ZapBoltIcon(tint = zapColor, size = 16)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Pay offer",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
recipientName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
val price = noffer.price
|
||||
if (price != null && price > 0) {
|
||||
Text(
|
||||
priceLabel(price, noffer.currency),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = zapColor
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showSheet) {
|
||||
NofferPaySheet(
|
||||
noffer = noffer,
|
||||
recipientProfile = profile,
|
||||
onPayInvoice = onPayInvoice,
|
||||
onDismiss = { showSheet = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZapBoltIcon(tint: Color, size: Int) {
|
||||
val useZapBolt = com.darkwisp.app.ui.util.useBoltIcon()
|
||||
if (useZapBolt) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_bolt),
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(size.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Outlined.CurrencyBitcoin,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(size.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom sheet that requests a bolt11 invoice for a CLINK offer and pays it
|
||||
* with the active wallet via [onPayInvoice], or falls back to a scannable
|
||||
* bare-`noffer` QR for an external CLINK-aware wallet (Zeus, ShockWallet, …).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NofferPaySheet(
|
||||
noffer: NofferData,
|
||||
recipientProfile: ProfileData? = null,
|
||||
onPayInvoice: (suspend (String) -> Boolean)? = null,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val zapColor = WispThemeColors.zapColor
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Spontaneous offers require the payer to name an amount. Fixed/Variable
|
||||
// let the service decide (Fixed from the offer, Variable on request).
|
||||
val needsAmountField = noffer.pricing == NofferPricing.SPONTANEOUS
|
||||
|
||||
var amountText by remember {
|
||||
mutableStateOf(
|
||||
if (needsAmountField && (noffer.price ?: 0) > 0) noffer.price.toString() else ""
|
||||
)
|
||||
}
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
var inFlight by remember { mutableStateOf(false) }
|
||||
var didPay by remember { mutableStateOf(false) }
|
||||
var showExternal by remember { mutableStateOf(false) }
|
||||
|
||||
val recipientName = recipientProfile?.displayString ?: shortNpub(noffer.pubkey)
|
||||
val amountSats = amountText.filter { it.isDigit() }.toLongOrNull()?.takeIf { it > 0 }
|
||||
val canPay = onPayInvoice != null && !inFlight && (!needsAmountField || amountSats != null)
|
||||
|
||||
fun pay() {
|
||||
val payInvoice = onPayInvoice ?: return
|
||||
scope.launch {
|
||||
inFlight = true
|
||||
status = null
|
||||
try {
|
||||
val keypair = withContext(Dispatchers.IO) { KeyRepository(context.applicationContext).getKeypair() }
|
||||
if (keypair == null) {
|
||||
status = "Sign in to pay an offer."
|
||||
return@launch
|
||||
}
|
||||
val bolt11 = NofferClient.requestInvoice(
|
||||
noffer = noffer,
|
||||
keypair = keypair,
|
||||
amountSats = if (needsAmountField) amountSats else null
|
||||
)
|
||||
// Map the payment hash to the offer's service pubkey so the
|
||||
// wallet transaction history resolves the payee, same as zaps.
|
||||
Bolt11.decode(bolt11)?.paymentHash?.let {
|
||||
ZapSender.persistRecipient(it, noffer.pubkey)
|
||||
}
|
||||
if (payInvoice(bolt11)) {
|
||||
didPay = true
|
||||
} else {
|
||||
status = "Payment failed."
|
||||
}
|
||||
} catch (err: NofferException) {
|
||||
status = if (err.code == 5 && err.rangeMin != null && err.rangeMax != null) {
|
||||
"Amount must be between ${AmountFormatter.formatSatsOnly(err.rangeMin!!)} and ${AmountFormatter.formatSatsOnly(err.rangeMax!!)} sats."
|
||||
} else {
|
||||
err.message ?: "The offer request failed."
|
||||
}
|
||||
} catch (err: Exception) {
|
||||
status = err.message ?: "The offer request failed."
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
// Full-height sheet to match the iOS pay modal — inset below the
|
||||
// status bar so the drag handle clears the camera cutout.
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
// Recipient header
|
||||
ProfilePicture(url = recipientProfile?.picture, size = 64)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
recipientName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (didPay) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = zapColor,
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Payment sent", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
TextButton(onClick = onDismiss) { Text("Done") }
|
||||
} else {
|
||||
// Offer details
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Offer",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
when (noffer.pricing) {
|
||||
NofferPricing.FIXED -> "Fixed price"
|
||||
NofferPricing.VARIABLE -> "Variable price"
|
||||
NofferPricing.SPONTANEOUS -> "Pay what you want"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
val price = noffer.price
|
||||
if (price != null && price > 0 && !needsAmountField) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Amount",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
priceLabel(price, noffer.currency),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = zapColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsAmountField) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = amountText,
|
||||
onValueChange = { new -> amountText = new.filter { it.isDigit() } },
|
||||
label = { Text("Amount in sats") },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
status?.let {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ErrorOutline,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { pay() },
|
||||
enabled = canPay,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = zapColor,
|
||||
contentColor = Color.White
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (inFlight) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
Text(if (onPayInvoice == null) "Connect a wallet to pay" else "Pay")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// External wallet fallback — bare-noffer QR per spec
|
||||
TextButton(onClick = { showExternal = !showExternal }) {
|
||||
Icon(Icons.Default.QrCode, contentDescription = null, modifier = Modifier.size(18.dp), tint = zapColor)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
if (showExternal) "Hide QR code" else "Pay with another wallet",
|
||||
color = zapColor
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
if (showExternal) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = zapColor
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = showExternal) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val qr = remember(noffer.raw) { generateQrBitmap(noffer.raw) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(230.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.White)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Image(
|
||||
bitmap = qr.asImageBitmap(),
|
||||
contentDescription = "CLINK offer QR code",
|
||||
modifier = Modifier.size(206.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"Scan with Zeus, ShockWallet, or another CLINK-aware wallet.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
TextButton(onClick = {
|
||||
clipboardManager.setText(AnnotatedString(noffer.raw))
|
||||
}) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp), tint = zapColor)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Copy offer", color = zapColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.SwapHoriz
|
||||
import androidx.compose.material.icons.outlined.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
@@ -50,6 +51,7 @@ import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.darkwisp.app.R
|
||||
import com.darkwisp.app.nostr.Nip19
|
||||
import com.darkwisp.app.nostr.Noffer
|
||||
import com.darkwisp.app.nostr.hexToByteArray
|
||||
import com.darkwisp.app.toRoute
|
||||
import com.darkwisp.app.ui.theme.WispThemeColors
|
||||
@@ -65,6 +67,7 @@ fun ProfileQrSheet(
|
||||
pubkeyHex: String,
|
||||
avatarUrl: String? = null,
|
||||
lud16: String? = null,
|
||||
clinkOffer: String? = null,
|
||||
onNavigate: (String) -> Unit = {},
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
@@ -77,12 +80,23 @@ fun ProfileQrSheet(
|
||||
val npub = remember(pubkeyHex) { Nip19.npubEncode(pubkeyHex.hexToByteArray()) }
|
||||
val npubQr = remember(npub) { generateQrBitmap(npub) }
|
||||
val lightningQr = remember(lud16) { lud16?.let { generateQrBitmap(it) } }
|
||||
// QR payload must be exactly the bech32 string per the CLINK spec.
|
||||
val bareOffer = remember(clinkOffer) {
|
||||
clinkOffer?.takeIf { Noffer.isNofferString(it) }?.let { Noffer.stripNostrPrefix(it) }
|
||||
}
|
||||
val offerQr = remember(bareOffer) { bareOffer?.let { generateQrBitmap(it) } }
|
||||
|
||||
val hasLightning = lud16 != null
|
||||
val hasOffer = bareOffer != null
|
||||
// The Lightning pane shows the address QR and/or the CLINK offer QR.
|
||||
val hasPayment = hasLightning || hasOffer
|
||||
// True = the offer QR is showing instead of the address QR. Offer-only
|
||||
// profiles always show the offer.
|
||||
var showOffer by remember { mutableStateOf(!hasLightning) }
|
||||
// Page indices: 0 = Nostr, 1 = Lightning (if present), last = Scan.
|
||||
val lightningPage = if (hasLightning) 1 else -1
|
||||
val scanPage = if (hasLightning) 2 else 1
|
||||
val pageCount = if (hasLightning) 3 else 2
|
||||
val lightningPage = if (hasPayment) 1 else -1
|
||||
val scanPage = if (hasPayment) 2 else 1
|
||||
val pageCount = if (hasPayment) 3 else 2
|
||||
val pagerState = rememberPagerState(pageCount = { pageCount })
|
||||
var scanError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -108,7 +122,7 @@ fun ProfileQrSheet(
|
||||
text = { Text("Nostr") },
|
||||
icon = { Icon(Icons.Outlined.Person, null, modifier = Modifier.size(18.dp)) }
|
||||
)
|
||||
if (hasLightning) {
|
||||
if (hasPayment) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == lightningPage,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(lightningPage) } },
|
||||
@@ -196,7 +210,9 @@ fun ProfileQrSheet(
|
||||
})
|
||||
}
|
||||
lightningPage -> {
|
||||
if (lightningQr != null && lud16 != null) {
|
||||
val payQr = if (showOffer) offerQr else lightningQr
|
||||
val payValue = if (showOffer) bareOffer else lud16
|
||||
if (payQr != null && payValue != null) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
@@ -206,8 +222,8 @@ fun ProfileQrSheet(
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Image(
|
||||
bitmap = lightningQr.asImageBitmap(),
|
||||
contentDescription = "Lightning QR Code",
|
||||
bitmap = payQr.asImageBitmap(),
|
||||
contentDescription = if (showOffer) "CLINK offer QR Code" else "Lightning QR Code",
|
||||
modifier = Modifier.matchParentSize()
|
||||
)
|
||||
Box(
|
||||
@@ -239,14 +255,29 @@ fun ProfileQrSheet(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
"Lightning Address",
|
||||
if (showOffer) "CLINK Offer" else "Lightning Address",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
CopyableRow(text = lud16, onCopy = {
|
||||
clipboardManager.setText(AnnotatedString(lud16))
|
||||
CopyableRow(text = payValue, onCopy = {
|
||||
clipboardManager.setText(AnnotatedString(payValue))
|
||||
})
|
||||
|
||||
// Only when the profile has both artifacts; a
|
||||
// single artifact needs no switch.
|
||||
if (hasLightning && hasOffer) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
androidx.compose.material3.TextButton(onClick = { showOffer = !showOffer }) {
|
||||
Icon(
|
||||
Icons.Default.SwapHoriz,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(if (showOffer) "Switch to Lightning address" else "Switch to CLINK Offer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scanPage -> {
|
||||
|
||||
@@ -106,6 +106,9 @@ import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.TextButton
|
||||
import com.darkwisp.app.R
|
||||
import com.darkwisp.app.nostr.Bolt11
|
||||
import com.darkwisp.app.nostr.Noffer
|
||||
import com.darkwisp.app.nostr.NofferData
|
||||
import com.darkwisp.app.nostr.toNpub
|
||||
import com.darkwisp.app.nostr.Nip19
|
||||
import com.darkwisp.app.nostr.Nip30
|
||||
import com.darkwisp.app.nostr.toHex
|
||||
@@ -202,6 +205,8 @@ internal sealed interface ContentSegment {
|
||||
data class CustomEmojiSegment(val shortcode: String, val url: String) : ContentSegment
|
||||
data class HashtagSegment(val tag: String) : ContentSegment
|
||||
data class LightningInvoiceSegment(val invoice: String, val decoded: Bolt11.DecodedInvoice) : ContentSegment
|
||||
/** CLINK payment offer (`noffer1…`) — rendered as a "Pay offer" card. */
|
||||
data class NofferSegment(val noffer: NofferData) : ContentSegment
|
||||
data class GroupInviteSegment(val relayUrl: String, val groupId: String) : ContentSegment
|
||||
}
|
||||
|
||||
@@ -380,10 +385,20 @@ internal fun parseContent(content: String, emojiMap: Map<String, String> = empty
|
||||
}
|
||||
|
||||
// Third pass: detect lightning invoices in text segments
|
||||
val finalResult = mutableListOf<ContentSegment>()
|
||||
val afterInvoices = mutableListOf<ContentSegment>()
|
||||
for (segment in afterEmoji) {
|
||||
if (segment is ContentSegment.TextSegment) {
|
||||
finalResult.addAll(splitTextForInvoices(segment.text))
|
||||
afterInvoices.addAll(splitTextForInvoices(segment.text))
|
||||
} else {
|
||||
afterInvoices.add(segment)
|
||||
}
|
||||
}
|
||||
|
||||
// Fourth pass: detect CLINK offers (noffer1…) in text segments
|
||||
val finalResult = mutableListOf<ContentSegment>()
|
||||
for (segment in afterInvoices) {
|
||||
if (segment is ContentSegment.TextSegment) {
|
||||
finalResult.addAll(splitTextForNoffers(segment.text))
|
||||
} else {
|
||||
finalResult.add(segment)
|
||||
}
|
||||
@@ -431,6 +446,33 @@ private fun splitTextForInvoices(text: String): List<ContentSegment> {
|
||||
return result
|
||||
}
|
||||
|
||||
private val nofferRegex = Regex(
|
||||
"""(?:nostr:)?noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}""",
|
||||
RegexOption.IGNORE_CASE
|
||||
)
|
||||
|
||||
private fun splitTextForNoffers(text: String): List<ContentSegment> {
|
||||
val matches = nofferRegex.findAll(text).toList()
|
||||
if (matches.isEmpty()) return listOf(ContentSegment.TextSegment(text))
|
||||
val result = mutableListOf<ContentSegment>()
|
||||
var lastEnd = 0
|
||||
var anyFound = false
|
||||
for (match in matches) {
|
||||
val decoded = Noffer.decodeOrNull(match.value) ?: continue
|
||||
anyFound = true
|
||||
if (match.range.first > lastEnd) {
|
||||
result.add(ContentSegment.TextSegment(text.substring(lastEnd, match.range.first)))
|
||||
}
|
||||
result.add(ContentSegment.NofferSegment(decoded))
|
||||
lastEnd = match.range.last + 1
|
||||
}
|
||||
if (!anyFound) return listOf(ContentSegment.TextSegment(text))
|
||||
if (lastEnd < text.length) {
|
||||
result.add(ContentSegment.TextSegment(text.substring(lastEnd)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun splitTextForEmojis(text: String, emojiMap: Map<String, String>): List<ContentSegment> {
|
||||
val result = mutableListOf<ContentSegment>()
|
||||
var lastEnd = 0
|
||||
@@ -939,6 +981,13 @@ fun RichContent(
|
||||
onPayInvoice = noteActions?.onPayInvoice
|
||||
)
|
||||
}
|
||||
is ContentSegment.NofferSegment -> {
|
||||
NofferCard(
|
||||
noffer = segment.noffer,
|
||||
eventRepo = eventRepo,
|
||||
onPayInvoice = noteActions?.onPayInvoice
|
||||
)
|
||||
}
|
||||
is ContentSegment.GroupInviteSegment -> {
|
||||
GroupInviteCard(
|
||||
relayUrl = segment.relayUrl,
|
||||
|
||||
@@ -365,6 +365,7 @@ fun WispDrawerContent(
|
||||
pubkeyHex = pubkey,
|
||||
avatarUrl = profile?.picture,
|
||||
lud16 = profile?.lud16,
|
||||
clinkOffer = profile?.clinkOffer,
|
||||
onNavigate = { route ->
|
||||
showProfileQr = false
|
||||
onScanResult(route)
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.darkwisp.app.relay.RelayPool
|
||||
import com.darkwisp.app.viewmodel.ProfileViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import com.darkwisp.app.ui.component.NsecPasteGuard
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -62,6 +63,7 @@ fun ProfileEditScreen(
|
||||
val nip05 by viewModel.nip05.collectAsState()
|
||||
val banner by viewModel.banner.collectAsState()
|
||||
val lud16 by viewModel.lud16.collectAsState()
|
||||
val clinkOffer by viewModel.clinkOffer.collectAsState()
|
||||
val publishing by viewModel.publishing.collectAsState()
|
||||
val uploading by viewModel.uploading.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
@@ -197,6 +199,18 @@ fun ProfileEditScreen(
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().scrollOnFocus()
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = clinkOffer,
|
||||
onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(clinkOffer, new)) viewModel.updateClinkOffer(new) },
|
||||
label = { Text("CLINK offer") },
|
||||
placeholder = { Text("noffer1…") },
|
||||
singleLine = true,
|
||||
supportingText = {
|
||||
Text("Self-custodial Lightning payments. Generate one with Zeus, ShockWallet or Lightning.Pub.")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().scrollOnFocus()
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
error?.let {
|
||||
|
||||
@@ -113,7 +113,8 @@ fun SearchScreen(
|
||||
onAddEmojiSet: ((String, String) -> Unit)? = null,
|
||||
onRemoveEmojiSet: ((String, String) -> Unit)? = null,
|
||||
isEmojiSetAdded: ((String, String) -> Boolean)? = null,
|
||||
nip05Repo: com.darkwisp.app.repo.Nip05Repository? = null
|
||||
nip05Repo: com.darkwisp.app.repo.Nip05Repository? = null,
|
||||
onPayInvoice: (suspend (String) -> Boolean)? = null
|
||||
) {
|
||||
val query by viewModel.query.collectAsState()
|
||||
val filter by viewModel.filter.collectAsState()
|
||||
@@ -145,6 +146,7 @@ fun SearchScreen(
|
||||
isEmojiSetAdded = isEmojiSetAdded,
|
||||
onPollVote = onPollVote,
|
||||
nip05Repo = nip05Repo,
|
||||
onPayInvoice = onPayInvoice,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,13 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.PersonRemove
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Block
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
@@ -40,6 +45,7 @@ import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.outlined.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.outlined.Sell
|
||||
import com.darkwisp.app.nostr.Nip30
|
||||
import com.darkwisp.app.ui.component.Nip05Badge
|
||||
import com.darkwisp.app.ui.component.PaymentTargetSheet
|
||||
@@ -90,6 +96,7 @@ import com.darkwisp.app.nostr.Nip69
|
||||
import com.darkwisp.app.nostr.NipA3
|
||||
import com.darkwisp.app.nostr.NostrEvent
|
||||
import com.darkwisp.app.nostr.ProfileData
|
||||
import com.darkwisp.app.nostr.toNpub
|
||||
import com.darkwisp.app.relay.RelayConfig
|
||||
import com.darkwisp.app.relay.RelayPool
|
||||
import com.darkwisp.app.repo.ContactRepository
|
||||
@@ -356,11 +363,27 @@ fun UserProfileScreen(
|
||||
var showQrDialog by remember { mutableStateOf(false) }
|
||||
var showAddToListDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Decoded CLINK offer advertised on the profile, when present and valid.
|
||||
val profileClinkOffer = remember(profile?.clinkOffer) {
|
||||
profile?.clinkOffer?.let { com.darkwisp.app.nostr.Noffer.decodeOrNull(it) }
|
||||
}
|
||||
var showOfferPaySheet by remember { mutableStateOf(false) }
|
||||
|
||||
if (showOfferPaySheet && profileClinkOffer != null) {
|
||||
com.darkwisp.app.ui.component.NofferPaySheet(
|
||||
noffer = profileClinkOffer,
|
||||
recipientProfile = profile,
|
||||
onPayInvoice = onPayInvoice,
|
||||
onDismiss = { showOfferPaySheet = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showQrDialog) {
|
||||
ProfileQrSheet(
|
||||
pubkeyHex = profilePubkey,
|
||||
avatarUrl = profile?.picture,
|
||||
lud16 = profile?.lud16,
|
||||
clinkOffer = profile?.clinkOffer,
|
||||
onDismiss = { showQrDialog = false }
|
||||
)
|
||||
}
|
||||
@@ -568,10 +591,13 @@ fun UserProfileScreen(
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onSendDm = onSendDm,
|
||||
onZapClick = if (onZapProfile != null) { { showProfileZapDialog = true } } else null,
|
||||
onPayOffer = if (profileClinkOffer != null) { { showOfferPaySheet = true } } else null,
|
||||
followingCount = followList.size,
|
||||
followedBy = followedBy,
|
||||
followsYou = !isOwnProfile && userPubkey != null && followList.any { it.pubkey == userPubkey },
|
||||
isBlocked = isBlocked,
|
||||
onMuteUser = onBlockUser,
|
||||
onUnmuteUser = onUnblockUser,
|
||||
paymentTargets = paymentTargets,
|
||||
onPaymentTargetClick = { paymentTargetSheetTarget = it }
|
||||
)
|
||||
@@ -1145,10 +1171,13 @@ private fun ProfileHeader(
|
||||
onNavigateToProfile: ((String) -> Unit)? = null,
|
||||
onSendDm: (() -> Unit)? = null,
|
||||
onZapClick: (() -> Unit)? = null,
|
||||
onPayOffer: (() -> Unit)? = null,
|
||||
followingCount: Int = 0,
|
||||
followedBy: List<String> = emptyList(),
|
||||
followsYou: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
onMuteUser: (() -> Unit)? = null,
|
||||
onUnmuteUser: (() -> Unit)? = null,
|
||||
paymentTargets: List<NipA3.PaymentTarget> = emptyList(),
|
||||
onPaymentTargetClick: (NipA3.PaymentTarget) -> Unit = {}
|
||||
) {
|
||||
@@ -1232,10 +1261,51 @@ private fun ProfileHeader(
|
||||
}
|
||||
}
|
||||
}
|
||||
FollowButton(
|
||||
isFollowing = isFollowing,
|
||||
onClick = onToggleFollow
|
||||
)
|
||||
if (onPayOffer != null) {
|
||||
Surface(
|
||||
onClick = onPayOffer,
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Sell,
|
||||
contentDescription = "Pay offer",
|
||||
tint = Color(0xFFFFC107),
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Follow circle button
|
||||
Surface(
|
||||
onClick = onToggleFollow,
|
||||
shape = CircleShape,
|
||||
color = if (isFollowing) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd,
|
||||
contentDescription = if (isFollowing) "Unfollow" else "Follow",
|
||||
tint = if (isFollowing) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.padding(9.dp)
|
||||
)
|
||||
}
|
||||
// Mute circle button
|
||||
if (onMuteUser != null || onUnmuteUser != null) {
|
||||
Surface(
|
||||
onClick = { if (isBlocked) onUnmuteUser?.invoke() else onMuteUser?.invoke() },
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isBlocked) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (isBlocked) "Unmute" else "Mute",
|
||||
tint = if (isBlocked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(9.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,8 +437,14 @@ fun WalletScreen(
|
||||
)
|
||||
is WalletPage.SendAmount -> {
|
||||
val page = currentPage as WalletPage.SendAmount
|
||||
// For a CLINK offer, show the service's profile name
|
||||
// instead of the raw noffer string.
|
||||
val nofferPayee = remember(page.address) {
|
||||
com.darkwisp.app.nostr.Noffer.decodeOrNull(page.address)
|
||||
?.let { viewModel.getProfileData(it.pubkey)?.displayString }
|
||||
}
|
||||
SendAmountContent(
|
||||
address = page.address,
|
||||
address = nofferPayee ?: page.address,
|
||||
amount = viewModel.sendAmount.collectAsState().value,
|
||||
error = viewModel.sendError.collectAsState().value,
|
||||
isLoading = viewModel.isLoading.collectAsState().value,
|
||||
@@ -1835,8 +1841,14 @@ private fun SendAmountContent(
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
// A noffer bech32 is far too long to show whole — ellipsize the middle.
|
||||
val displayAddress = if (com.darkwisp.app.nostr.Noffer.isNofferString(address)) {
|
||||
"${address.take(16)}…${address.takeLast(8)}"
|
||||
} else {
|
||||
address
|
||||
}
|
||||
Text(
|
||||
address,
|
||||
displayAddress,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.darkwisp.app.nostr.ClientMessage
|
||||
import com.darkwisp.app.nostr.Filter
|
||||
import com.darkwisp.app.nostr.LocalSigner
|
||||
import com.darkwisp.app.nostr.Noffer
|
||||
import com.darkwisp.app.nostr.NostrEvent
|
||||
import com.darkwisp.app.nostr.NostrSigner
|
||||
import com.darkwisp.app.relay.RelayPool
|
||||
@@ -47,6 +48,9 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val _lud16 = MutableStateFlow("")
|
||||
val lud16: StateFlow<String> = _lud16
|
||||
|
||||
private val _clinkOffer = MutableStateFlow("")
|
||||
val clinkOffer: StateFlow<String> = _clinkOffer
|
||||
|
||||
private val _publishing = MutableStateFlow(false)
|
||||
val publishing: StateFlow<Boolean> = _publishing
|
||||
|
||||
@@ -59,6 +63,7 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) {
|
||||
fun updateNip05(value: String) { _nip05.value = value }
|
||||
fun updateBanner(value: String) { _banner.value = value }
|
||||
fun updateLud16(value: String) { _lud16.value = value }
|
||||
fun updateClinkOffer(value: String) { _clinkOffer.value = value }
|
||||
|
||||
private val _uploading = MutableStateFlow<String?>(null)
|
||||
val uploading: StateFlow<String?> = _uploading
|
||||
@@ -107,6 +112,7 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_nip05.value = profile.nip05 ?: ""
|
||||
_banner.value = profile.banner ?: ""
|
||||
_lud16.value = profile.lud16 ?: ""
|
||||
_clinkOffer.value = profile.clinkOffer ?: ""
|
||||
}
|
||||
|
||||
// Request fresh profile from relays
|
||||
@@ -129,6 +135,7 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_nip05.value = updated.nip05 ?: ""
|
||||
_banner.value = updated.banner ?: ""
|
||||
_lud16.value = updated.lud16 ?: ""
|
||||
_clinkOffer.value = updated.clinkOffer ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +160,9 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) {
|
||||
if (_nip05.value.isNotBlank()) put("nip05", JsonPrimitive(_nip05.value))
|
||||
if (_banner.value.isNotBlank()) put("banner", JsonPrimitive(_banner.value))
|
||||
if (_lud16.value.isNotBlank()) put("lud16", JsonPrimitive(_lud16.value))
|
||||
if (_clinkOffer.value.isNotBlank()) {
|
||||
put("clink_offer", JsonPrimitive(Noffer.stripNostrPrefix(_clinkOffer.value)))
|
||||
}
|
||||
}.toString()
|
||||
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -9,6 +9,10 @@ import com.darkwisp.app.nostr.LocalSigner
|
||||
import com.darkwisp.app.nostr.Nip57
|
||||
import com.darkwisp.app.nostr.Nip78
|
||||
import com.darkwisp.app.nostr.NipA3
|
||||
import com.darkwisp.app.nostr.Noffer
|
||||
import com.darkwisp.app.nostr.NofferData
|
||||
import com.darkwisp.app.nostr.NofferException
|
||||
import com.darkwisp.app.nostr.NofferPricing
|
||||
import com.darkwisp.app.nostr.NostrEvent
|
||||
import com.darkwisp.app.nostr.NostrSigner
|
||||
import com.darkwisp.app.nostr.RemoteSigner
|
||||
@@ -21,6 +25,7 @@ import com.darkwisp.app.repo.SigningMode
|
||||
import com.darkwisp.app.repo.EventRepository
|
||||
import com.darkwisp.app.repo.KeyRepository
|
||||
import com.darkwisp.app.repo.BalanceUnit
|
||||
import com.darkwisp.app.repo.NofferClient
|
||||
import com.darkwisp.app.repo.NwcRepository
|
||||
import com.darkwisp.app.repo.SparkRepository
|
||||
import com.darkwisp.app.repo.WalletMode
|
||||
@@ -1332,6 +1337,23 @@ class WalletViewModel(
|
||||
_sendError.value = null
|
||||
|
||||
when {
|
||||
Noffer.isNofferString(trimmed) -> {
|
||||
val noffer = Noffer.decodeOrNull(trimmed)
|
||||
if (noffer == null) {
|
||||
_sendError.value = "Invalid CLINK offer"
|
||||
return
|
||||
}
|
||||
// Resolve the offer service's profile so the send pages and
|
||||
// transaction history can show the payee by name.
|
||||
eventRepo.requestProfileIfMissing(noffer.pubkey)
|
||||
if (noffer.pricing == NofferPricing.FIXED) {
|
||||
// Amount is baked into the offer — go straight to the invoice.
|
||||
resolveNofferInvoice(noffer, null)
|
||||
} else {
|
||||
_sendAmount.value = ""
|
||||
navigateTo(WalletPage.SendAmount(noffer.raw))
|
||||
}
|
||||
}
|
||||
trimmed.lowercase().startsWith("lnbc") -> {
|
||||
val decoded = Bolt11.decode(trimmed)
|
||||
if (decoded == null) {
|
||||
@@ -1354,12 +1376,23 @@ class WalletViewModel(
|
||||
navigateTo(WalletPage.SendAmount(trimmed))
|
||||
}
|
||||
else -> {
|
||||
_sendError.value = "Enter a lightning address (user@domain) or BOLT11 invoice"
|
||||
_sendError.value = "Enter a lightning address (user@domain), BOLT11 invoice, or CLINK offer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveLightningAddress(address: String, amountSats: Long) {
|
||||
// The SendAmount page is shared between lightning addresses and CLINK
|
||||
// offers — route noffer-shaped input through the kind-21001 RPC.
|
||||
if (Noffer.isNofferString(address)) {
|
||||
val noffer = Noffer.decodeOrNull(address)
|
||||
if (noffer == null) {
|
||||
_sendError.value = "Invalid CLINK offer"
|
||||
return
|
||||
}
|
||||
resolveNofferInvoice(noffer, amountSats)
|
||||
return
|
||||
}
|
||||
_isLoading.value = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -1399,6 +1432,48 @@ class WalletViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a bolt11 invoice for a CLINK offer via the kind-21001 RPC and
|
||||
* move to the confirm page. [amountSats] is null for Fixed offers (the
|
||||
* service knows the price).
|
||||
*/
|
||||
private fun resolveNofferInvoice(noffer: NofferData, amountSats: Long?) {
|
||||
_isLoading.value = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val keypair = keyRepo.getKeypair()
|
||||
if (keypair == null) {
|
||||
_sendError.value = "Sign in with a key that can sign to pay an offer"
|
||||
return@launch
|
||||
}
|
||||
val invoice = NofferClient.requestInvoice(noffer, keypair, amountSats)
|
||||
val decoded = Bolt11.decode(invoice)
|
||||
// Map the payment hash to the offer's service pubkey so the
|
||||
// transaction history resolves the payee's profile, same as zaps.
|
||||
decoded?.paymentHash?.let { ZapSender.persistRecipient(it, noffer.pubkey) }
|
||||
val payeeName = eventRepo.getProfileData(noffer.pubkey)?.displayString
|
||||
navigateTo(WalletPage.SendConfirm(
|
||||
invoice = invoice,
|
||||
amountSats = decoded?.amountSats ?: amountSats,
|
||||
paymentHash = decoded?.paymentHash,
|
||||
description = decoded?.description?.takeIf { it.isNotBlank() }
|
||||
?: payeeName?.let { "CLINK offer to $it" }
|
||||
?: "CLINK offer"
|
||||
))
|
||||
} catch (e: NofferException) {
|
||||
_sendError.value = if (e.code == 5 && e.rangeMin != null && e.rangeMax != null) {
|
||||
"Amount out of range: ${e.rangeMin}-${e.rangeMax} sats"
|
||||
} else {
|
||||
e.message ?: "Failed to resolve offer"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_sendError.value = e.message ?: "Failed to resolve offer"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFee(invoice: String) {
|
||||
if (_walletMode.value != WalletMode.SPARK) {
|
||||
_feeState.value = FeeState.Unavailable
|
||||
|
||||
@@ -735,7 +735,7 @@
|
||||
<string name="wallet_set_up_lightning">Set up your Lightning Address</string>
|
||||
<string name="wallet_send">Send</string>
|
||||
<string name="wallet_receive">Receive</string>
|
||||
<string name="wallet_lightning_address_invoice">Lightning address or invoice</string>
|
||||
<string name="wallet_lightning_address_invoice">Lightning address, invoice, or CLINK offer</string>
|
||||
<string name="wallet_paste">Paste</string>
|
||||
<string name="wallet_scan_qr">Scan QR</string>
|
||||
<string name="wallet_gallery">Gallery</string>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.darkwisp.app.nostr
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for the CLINK `noffer1…` decoder. We don't have a publicly-shared
|
||||
* production noffer to pin as a fixture, so each test encodes a synthetic
|
||||
* offer via the app's own bech32 encoder and round-trips it through
|
||||
* [Noffer.decode], pinning the TLV byte layout from the CLINK spec.
|
||||
*/
|
||||
class NofferTest {
|
||||
|
||||
private val pubkeyHex = "ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74"
|
||||
private val relay = "wss://relay.example.com"
|
||||
private val offerId = "tip-jar"
|
||||
|
||||
private fun encode(tlvs: List<Pair<Int, ByteArray>>): String {
|
||||
val bytes = mutableListOf<Byte>()
|
||||
for ((type, value) in tlvs) {
|
||||
bytes.add(type.toByte())
|
||||
bytes.add(value.size.toByte())
|
||||
for (b in value) bytes.add(b)
|
||||
}
|
||||
return Nip19.bech32Encode("noffer", bytes.toByteArray())
|
||||
}
|
||||
|
||||
private val pubkeyBytes get() = pubkeyHex.hexToByteArray()
|
||||
private val relayBytes get() = relay.toByteArray(Charsets.UTF_8)
|
||||
private val offerBytes get() = offerId.toByteArray(Charsets.UTF_8)
|
||||
|
||||
@Test
|
||||
fun decodesMinimumTlvsAndDefaultsToSpontaneous() {
|
||||
val noffer = encode(listOf(0 to pubkeyBytes, 1 to relayBytes, 2 to offerBytes))
|
||||
val decoded = Noffer.decode(noffer)
|
||||
assertEquals(pubkeyHex, decoded.pubkey)
|
||||
assertEquals(relay, decoded.relay)
|
||||
assertEquals(offerId, decoded.offerId)
|
||||
assertEquals(NofferPricing.SPONTANEOUS, decoded.pricing)
|
||||
assertNull(decoded.price)
|
||||
assertNull(decoded.currency)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesFixedPricingWithPrice() {
|
||||
val noffer = encode(listOf(
|
||||
0 to pubkeyBytes,
|
||||
1 to relayBytes,
|
||||
2 to offerBytes,
|
||||
3 to byteArrayOf(0), // Fixed
|
||||
4 to byteArrayOf(0x27, 0x10) // 10000 big-endian
|
||||
))
|
||||
val decoded = Noffer.decode(noffer)
|
||||
assertEquals(NofferPricing.FIXED, decoded.pricing)
|
||||
assertEquals(10_000L, decoded.price)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesVariablePricingWithCurrency() {
|
||||
val noffer = encode(listOf(
|
||||
0 to pubkeyBytes,
|
||||
1 to relayBytes,
|
||||
2 to offerBytes,
|
||||
3 to byteArrayOf(1), // Variable
|
||||
5 to "USD".toByteArray(Charsets.UTF_8)
|
||||
))
|
||||
val decoded = Noffer.decode(noffer)
|
||||
assertEquals(NofferPricing.VARIABLE, decoded.pricing)
|
||||
assertEquals("USD", decoded.currency)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsNostrUriPrefix() {
|
||||
val noffer = encode(listOf(0 to pubkeyBytes, 1 to relayBytes, 2 to offerBytes))
|
||||
assertEquals(pubkeyHex, Noffer.decode("nostr:$noffer").pubkey)
|
||||
assertEquals(pubkeyHex, Noffer.decode("NOSTR:$noffer").pubkey)
|
||||
// `raw` always strips the scheme prefix.
|
||||
assertEquals(noffer, Noffer.decode("nostr:$noffer").raw)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWrongHrp() {
|
||||
assertNull(Noffer.decodeOrNull("npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMissingRequiredTlvs() {
|
||||
val noffer = encode(listOf(0 to pubkeyBytes)) // missing relay + offer id
|
||||
assertNull(Noffer.decodeOrNull(noffer))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWrongLengthPubkey() {
|
||||
val noffer = encode(listOf(
|
||||
0 to ByteArray(16), // 16-byte pubkey is invalid
|
||||
1 to relayBytes,
|
||||
2 to offerBytes
|
||||
))
|
||||
assertNull(Noffer.decodeOrNull(noffer))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNofferStringMatchesShapeOnly() {
|
||||
val noffer = encode(listOf(0 to pubkeyBytes, 1 to relayBytes, 2 to offerBytes))
|
||||
assertTrue(Noffer.isNofferString(noffer))
|
||||
assertTrue(Noffer.isNofferString("nostr:$noffer"))
|
||||
assertFalse(Noffer.isNofferString("npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"))
|
||||
assertFalse(Noffer.isNofferString(""))
|
||||
assertFalse(Noffer.isNofferString(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripNostrPrefixWorks() {
|
||||
assertEquals("noffer1abc", Noffer.stripNostrPrefix("nostr:noffer1abc"))
|
||||
assertEquals("noffer1abc", Noffer.stripNostrPrefix("NOSTR:noffer1abc"))
|
||||
assertEquals("noffer1abc", Noffer.stripNostrPrefix(" nostr:noffer1abc "))
|
||||
assertEquals("noffer1abc", Noffer.stripNostrPrefix("noffer1abc"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user