diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index 80c11255..2dd479d2 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -1277,6 +1277,7 @@ fun WispNavHost( SearchScreen( viewModel = searchViewModel, relayPool = feedViewModel.relayPool, + onPayInvoice = { bolt11 -> feedViewModel.payInvoice(bolt11) }, eventRepo = feedViewModel.eventRepo, muteRepo = feedViewModel.muteRepo, contactRepo = feedViewModel.contactRepo, @@ -1435,6 +1436,7 @@ fun WispNavHost( noteActions = remember { com.wisp.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 -> @@ -1522,6 +1524,7 @@ fun WispNavHost( noteActions = remember { com.wisp.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 -> @@ -1757,6 +1760,7 @@ fun WispNavHost( noteActions = remember { com.wisp.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) }, diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Noffer.kt b/app/src/main/kotlin/com/wisp/app/nostr/Noffer.kt new file mode 100644 index 00000000..1d410a69 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/nostr/Noffer.kt @@ -0,0 +1,187 @@ +package com.wisp.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> { + val tlvs = mutableListOf>() + 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 + } +} diff --git a/app/src/main/kotlin/com/wisp/app/nostr/ProfileData.kt b/app/src/main/kotlin/com/wisp/app/nostr/ProfileData.kt index c021227d..7350a97b 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/ProfileData.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/ProfileData.kt @@ -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) { diff --git a/app/src/main/kotlin/com/wisp/app/repo/NofferClient.kt b/app/src/main/kotlin/com/wisp/app/repo/NofferClient.kt new file mode 100644 index 00000000..95988305 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/repo/NofferClient.kt @@ -0,0 +1,213 @@ +package com.wisp.app.repo + +import com.wisp.app.nostr.ClientMessage +import com.wisp.app.nostr.Filter +import com.wisp.app.nostr.Keys +import com.wisp.app.nostr.Nip44 +import com.wisp.app.nostr.NofferData +import com.wisp.app.nostr.NofferException +import com.wisp.app.nostr.Noffer +import com.wisp.app.nostr.NostrEvent +import com.wisp.app.nostr.RelayMessage +import com.wisp.app.nostr.hexToByteArray +import com.wisp.app.nostr.toHex +import com.wisp.app.relay.Relay +import com.wisp.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 + } + } +} diff --git a/app/src/main/kotlin/com/wisp/app/repo/ProfileRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/ProfileRepository.kt index 1608a893..3fba6ed4 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/ProfileRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/ProfileRepository.kt @@ -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) diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/NofferPaySheet.kt b/app/src/main/kotlin/com/wisp/app/ui/component/NofferPaySheet.kt new file mode 100644 index 00000000..aa1ac4f3 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/ui/component/NofferPaySheet.kt @@ -0,0 +1,477 @@ +package com.wisp.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.wisp.app.R +import com.wisp.app.nostr.Bolt11 +import com.wisp.app.nostr.NofferData +import com.wisp.app.nostr.NofferException +import com.wisp.app.nostr.NofferPricing +import com.wisp.app.nostr.ProfileData +import com.wisp.app.nostr.toNpub +import com.wisp.app.repo.EventRepository +import com.wisp.app.repo.KeyRepository +import com.wisp.app.repo.NofferClient +import com.wisp.app.repo.ZapSender +import com.wisp.app.ui.theme.WispThemeColors +import com.wisp.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.wisp.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(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) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ProfileQrSheet.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ProfileQrSheet.kt index 9d12c4bc..2aaef1f2 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ProfileQrSheet.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ProfileQrSheet.kt @@ -2,14 +2,19 @@ package com.wisp.app.ui.component 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.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -17,6 +22,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 +56,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.wisp.app.R import com.wisp.app.nostr.Nip19 +import com.wisp.app.nostr.Noffer import com.wisp.app.nostr.hexToByteArray import com.wisp.app.toRoute import com.wisp.app.ui.theme.WispThemeColors @@ -65,6 +72,7 @@ fun ProfileQrSheet( pubkeyHex: String, avatarUrl: String? = null, lud16: String? = null, + clinkOffer: String? = null, onNavigate: (String) -> Unit = {}, onDismiss: () -> Unit ) { @@ -77,69 +85,90 @@ 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(null) } ModalBottomSheet( onDismissRequest = onDismiss, - sheetState = sheetState + sheetState = sheetState, + // Full-height sheet matching NofferPaySheet, inset below the status + // bar so the drag handle clears the camera cutout. + modifier = Modifier + .fillMaxHeight() + .statusBarsPadding() ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .fillMaxWidth() + .fillMaxSize() .padding(bottom = 32.dp) ) { - TabRow( - selectedTabIndex = pagerState.currentPage, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.primary, - divider = {} + // iOS-style segmented control: rounded container with a pill + // highlight on the selected segment. + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(26.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)) + .padding(4.dp) ) { - Tab( + SegmentedTab( + label = "Nostr", selected = pagerState.currentPage == 0, - onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text("Nostr") }, - icon = { Icon(Icons.Outlined.Person, null, modifier = Modifier.size(18.dp)) } + modifier = Modifier.weight(1f), + onClick = { scope.launch { pagerState.animateScrollToPage(0) } } ) - if (hasLightning) { - Tab( + if (hasPayment) { + SegmentedTab( + label = "Lightning", selected = pagerState.currentPage == lightningPage, - onClick = { scope.launch { pagerState.animateScrollToPage(lightningPage) } }, - text = { Text("Lightning") }, - icon = { - if (useZapBolt) { - Icon(painterResource(R.drawable.ic_bolt), null, modifier = Modifier.size(18.dp)) - } else { - Icon(Icons.Outlined.CurrencyBitcoin, null, modifier = Modifier.size(18.dp)) - } - } + modifier = Modifier.weight(1f), + onClick = { scope.launch { pagerState.animateScrollToPage(lightningPage) } } ) } - Tab( + SegmentedTab( + label = "Scan", + icon = { Icon(Icons.Outlined.QrCodeScanner, null, modifier = Modifier.size(16.dp)) }, selected = pagerState.currentPage == scanPage, - onClick = { scope.launch { pagerState.animateScrollToPage(scanPage) } }, - text = { Text("Scan") }, - icon = { Icon(Icons.Outlined.QrCodeScanner, null, modifier = Modifier.size(18.dp)) } + modifier = Modifier.weight(1f), + onClick = { scope.launch { pagerState.animateScrollToPage(scanPage) } } ) } Spacer(Modifier.height(16.dp)) + // Fill the remaining sheet height and top-align every page so the + // pager doesn't resize/re-center when pages of different heights + // compose in — that's what made the content shift on tab change. HorizontalPager( state = pagerState, - modifier = Modifier.fillMaxWidth() + verticalAlignment = Alignment.Top, + modifier = Modifier + .fillMaxWidth() + .weight(1f) ) { page -> Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .fillMaxWidth() + .fillMaxSize() .padding(horizontal = 24.dp) ) { when (page) { @@ -196,7 +225,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,31 +237,39 @@ 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() ) + // Avatar in the QR center, matching iOS's + // qrWithCenterAvatar and the Nostr pane. Box( - contentAlignment = Alignment.Center, modifier = Modifier .size(44.dp) .clip(CircleShape) .background(androidx.compose.ui.graphics.Color.White) - .padding(4.dp) + .padding(3.dp) ) { - if (useZapBolt) { + if (avatarUrl != null) { + AsyncImage( + model = avatarUrl, + contentDescription = "Avatar", + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize().clip(CircleShape) + ) + } else if (useZapBolt) { Icon( painter = painterResource(R.drawable.ic_bolt), contentDescription = "Lightning", tint = WispThemeColors.zapColor, - modifier = Modifier.size(28.dp) + modifier = Modifier.matchParentSize().padding(4.dp) ) } else { Icon( Icons.Outlined.CurrencyBitcoin, contentDescription = "Bitcoin", tint = WispThemeColors.zapColor, - modifier = Modifier.size(28.dp) + modifier = Modifier.matchParentSize().padding(4.dp) ) } } @@ -239,14 +278,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 -> { @@ -279,6 +333,39 @@ fun ProfileQrSheet( } } +@Composable +private fun SegmentedTab( + label: String, + selected: Boolean, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + onClick: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clip(RoundedCornerShape(22.dp)) + .background( + if (selected) MaterialTheme.colorScheme.surfaceVariant + else androidx.compose.ui.graphics.Color.Transparent + ) + .clickable(onClick = onClick) + .padding(vertical = 12.dp) + ) { + if (icon != null) { + icon() + Spacer(Modifier.width(6.dp)) + } + Text( + label, + style = MaterialTheme.typography.labelLarge, + color = if (selected) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + @Composable private fun CopyableRow(text: String, onCopy: () -> Unit) { Row( diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt b/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt index 6cb7127c..6d8485cd 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt @@ -106,6 +106,8 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.TextButton import com.wisp.app.R import com.wisp.app.nostr.Bolt11 +import com.wisp.app.nostr.Noffer +import com.wisp.app.nostr.NofferData import com.wisp.app.nostr.toNpub import com.wisp.app.nostr.Nip19 import com.wisp.app.nostr.Nip30 @@ -205,6 +207,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 } @@ -383,10 +387,20 @@ internal fun parseContent(content: String, emojiMap: Map = empty } // Third pass: detect lightning invoices in text segments - val finalResult = mutableListOf() + val afterInvoices = mutableListOf() 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() + for (segment in afterInvoices) { + if (segment is ContentSegment.TextSegment) { + finalResult.addAll(splitTextForNoffers(segment.text)) } else { finalResult.add(segment) } @@ -434,6 +448,33 @@ private fun splitTextForInvoices(text: String): List { return result } +private val nofferRegex = Regex( + """(?:nostr:)?noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}""", + RegexOption.IGNORE_CASE +) + +private fun splitTextForNoffers(text: String): List { + val matches = nofferRegex.findAll(text).toList() + if (matches.isEmpty()) return listOf(ContentSegment.TextSegment(text)) + val result = mutableListOf() + 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): List { val result = mutableListOf() var lastEnd = 0 @@ -1024,6 +1065,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, diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/WispDrawerContent.kt b/app/src/main/kotlin/com/wisp/app/ui/component/WispDrawerContent.kt index d2f400d8..5a0153e5 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/WispDrawerContent.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/WispDrawerContent.kt @@ -370,6 +370,7 @@ fun WispDrawerContent( pubkeyHex = pubkey, avatarUrl = profile?.picture, lud16 = profile?.lud16, + clinkOffer = profile?.clinkOffer, onNavigate = { route -> showProfileQr = false onScanResult(route) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt index 7a9f2056..52840762 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt @@ -3,8 +3,14 @@ package com.wisp.app.ui.screen import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -19,7 +25,11 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.outlined.FileUpload +import androidx.compose.material.icons.outlined.PhotoCamera import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -34,14 +44,20 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberCoroutineScope 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.layout.ContentScale import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import coil3.compose.AsyncImage import com.wisp.app.R import com.wisp.app.relay.RelayPool import com.wisp.app.ui.component.NsecPasteGuard @@ -63,11 +79,13 @@ 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() val context = LocalContext.current val scope = rememberCoroutineScope() + var advancedExpanded by remember { mutableStateOf(false) } @Composable fun Modifier.scrollOnFocus(): Modifier { @@ -109,10 +127,90 @@ fun ProfileEditScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(16.dp) .verticalScroll(rememberScrollState()) .imePadding() ) { + // Tappable banner + avatar preview, matching iOS — tap either to + // pick a new image from the gallery. + Box( + modifier = Modifier + .fillMaxWidth() + .height(140.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(enabled = uploading == null) { + bannerPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) { + if (banner.isNotBlank()) { + AsyncImage( + model = banner, + contentDescription = stringResource(R.string.cd_upload_banner), + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + if (uploading != null && uploading!!.contains("banner")) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp).align(Alignment.Center), + strokeWidth = 2.dp + ) + } else { + Icon( + Icons.Outlined.PhotoCamera, + contentDescription = stringResource(R.string.cd_upload_banner), + tint = Color.White.copy(alpha = 0.85f), + modifier = Modifier + .align(Alignment.Center) + .size(32.dp) + .background(Color.Black.copy(alpha = 0.35f), CircleShape) + .padding(6.dp) + ) + } + } + Box( + modifier = Modifier + .padding(start = 16.dp) + .offset(y = (-32).dp) + .size(72.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border(2.dp, MaterialTheme.colorScheme.background, CircleShape) + .clickable(enabled = uploading == null) { + avatarPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) { + if (picture.isNotBlank()) { + AsyncImage( + model = picture, + contentDescription = stringResource(R.string.cd_upload_avatar), + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize().clip(CircleShape) + ) + } + if (uploading != null && uploading!!.contains("avatar")) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp).align(Alignment.Center), + strokeWidth = 2.dp + ) + } else { + Icon( + Icons.Outlined.PhotoCamera, + contentDescription = stringResource(R.string.cd_upload_avatar), + tint = Color.White.copy(alpha = 0.85f), + modifier = Modifier + .align(Alignment.Center) + .size(26.dp) + .background(Color.Black.copy(alpha = 0.35f), CircleShape) + .padding(5.dp) + ) + } + } + + Column(modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-16).dp)) { OutlinedTextField( value = name, onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(name, new)) viewModel.updateName(new) }, @@ -129,60 +227,6 @@ fun ProfileEditScreen( modifier = Modifier.fillMaxWidth().scrollOnFocus() ) Spacer(Modifier.height(12.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = picture, - onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(picture, new)) viewModel.updatePicture(new) }, - label = { Text(stringResource(R.string.placeholder_profile_picture_url)) }, - singleLine = true, - modifier = Modifier.weight(1f).scrollOnFocus() - ) - IconButton( - onClick = { - avatarPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - }, - enabled = uploading == null - ) { - if (uploading != null && uploading!!.contains("avatar")) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - } else { - Icon(Icons.Outlined.FileUpload, contentDescription = stringResource(R.string.cd_upload_avatar)) - } - } - } - Spacer(Modifier.height(12.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = banner, - onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(banner, new)) viewModel.updateBanner(new) }, - label = { Text(stringResource(R.string.placeholder_banner_url)) }, - singleLine = true, - modifier = Modifier.weight(1f).scrollOnFocus() - ) - IconButton( - onClick = { - bannerPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - }, - enabled = uploading == null - ) { - if (uploading != null && uploading!!.contains("banner")) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - } else { - Icon(Icons.Outlined.FileUpload, contentDescription = stringResource(R.string.cd_upload_banner)) - } - } - } - Spacer(Modifier.height(12.dp)) OutlinedTextField( value = nip05, onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(nip05, new)) viewModel.updateNip05(new) }, @@ -200,6 +244,98 @@ fun ProfileEditScreen( ) Spacer(Modifier.height(16.dp)) + // Advanced section — raw image URLs and the CLINK offer, matching iOS. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { advancedExpanded = !advancedExpanded } + .padding(vertical = 4.dp) + ) { + Text( + "Advanced", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.weight(1f)) + Icon( + if (advancedExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (advancedExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + AnimatedVisibility(visible = advancedExpanded) { + Column { + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = picture, + onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(picture, new)) viewModel.updatePicture(new) }, + label = { Text(stringResource(R.string.placeholder_profile_picture_url)) }, + singleLine = true, + modifier = Modifier.weight(1f).scrollOnFocus() + ) + IconButton( + onClick = { + avatarPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + enabled = uploading == null + ) { + if (uploading != null && uploading!!.contains("avatar")) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.FileUpload, contentDescription = stringResource(R.string.cd_upload_avatar)) + } + } + } + Spacer(Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = banner, + onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(banner, new)) viewModel.updateBanner(new) }, + label = { Text(stringResource(R.string.placeholder_banner_url)) }, + singleLine = true, + modifier = Modifier.weight(1f).scrollOnFocus() + ) + IconButton( + onClick = { + bannerPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + enabled = uploading == null + ) { + if (uploading != null && uploading!!.contains("banner")) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.FileUpload, contentDescription = stringResource(R.string.cd_upload_banner)) + } + } + } + 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 { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) Spacer(Modifier.height(8.dp)) @@ -214,6 +350,7 @@ fun ProfileEditScreen( ) { Text(if (publishing) stringResource(R.string.onboarding_publishing) else stringResource(R.string.btn_save_profile)) } + } } } } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt index 0aa0b7eb..870e1d2b 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt @@ -119,7 +119,8 @@ fun SearchScreen( onAddEmojiSet: ((String, String) -> Unit)? = null, onRemoveEmojiSet: ((String, String) -> Unit)? = null, isEmojiSetAdded: ((String, String) -> Boolean)? = null, - nip05Repo: com.wisp.app.repo.Nip05Repository? = null + nip05Repo: com.wisp.app.repo.Nip05Repository? = null, + onPayInvoice: (suspend (String) -> Boolean)? = null ) { val query by viewModel.query.collectAsState() val filter by viewModel.filter.collectAsState() @@ -151,6 +152,7 @@ fun SearchScreen( isEmojiSetAdded = isEmojiSetAdded, onPollVote = onPollVote, nip05Repo = nip05Repo, + onPayInvoice = onPayInvoice, ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt index ee8ccf25..e6e556b0 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt @@ -50,6 +50,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.wisp.app.nostr.Nip30 import com.wisp.app.nostr.toNpub import com.wisp.app.ui.component.Nip05Badge @@ -131,8 +132,6 @@ import android.widget.Toast import androidx.compose.ui.platform.LocalContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharedFlow -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put private sealed class ProfileZapStatus { object Idle : ProfileZapStatus() @@ -357,11 +356,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.wisp.app.nostr.Noffer.decodeOrNull(it) } + } + var showOfferPaySheet by remember { mutableStateOf(false) } + + if (showOfferPaySheet && profileClinkOffer != null) { + com.wisp.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 } ) } @@ -471,23 +486,29 @@ fun UserProfileScreen( onDismissRequest = { menuExpanded = false } ) { DropdownMenuItem( - text = { Text(stringResource(R.string.profile_copy_json)) }, + text = { Text("Share Profile") }, + onClick = { + menuExpanded = false + try { + val npub = profilePubkey.toNpub() + val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_TEXT, "https://njump.me/$npub") + } + context.startActivity(android.content.Intent.createChooser(intent, null)) + } catch (_: Exception) {} + } + ) + DropdownMenuItem( + text = { Text("Copy npub") }, onClick = { menuExpanded = false - profile?.let { p -> - val json = buildJsonObject { - p.name?.let { put("name", it) } - p.displayName?.let { put("display_name", it) } - p.about?.let { put("about", it) } - p.picture?.let { put("picture", it) } - p.banner?.let { put("banner", it) } - p.nip05?.let { put("nip05", it) } - p.lud16?.let { put("lud16", it) } - }.toString() + try { + val npub = profilePubkey.toNpub() val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("Profile JSON", json)) - Toast.makeText(context, context.getString(R.string.profile_json_copied), Toast.LENGTH_SHORT).show() - } + clipboard.setPrimaryClip(ClipData.newPlainText("npub", npub)) + Toast.makeText(context, "npub copied", Toast.LENGTH_SHORT).show() + } catch (_: Exception) {} } ) if (!isOwnProfile) { @@ -631,6 +652,7 @@ 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, followerCount = followers.size.takeIf { followers.isNotEmpty() }, followedBy = followedBy, @@ -1275,6 +1297,7 @@ private fun ProfileHeader( onNavigateToProfile: ((String) -> Unit)? = null, onSendDm: (() -> Unit)? = null, onZapClick: (() -> Unit)? = null, + onPayOffer: (() -> Unit)? = null, followingCount: Int = 0, followerCount: Int? = null, followedBy: List = emptyList(), @@ -1386,6 +1409,21 @@ private fun ProfileHeader( } } } + 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, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index a6f1f93d..66012335 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -388,8 +388,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.wisp.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, @@ -1595,8 +1601,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.wisp.app.nostr.Noffer.isNofferString(address)) { + "${address.take(16)}…${address.takeLast(8)}" + } else { + address + } Text( - address, + displayAddress, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface ) diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/ProfileViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/ProfileViewModel.kt index dd953fc0..63a07d49 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/ProfileViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/ProfileViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope import com.wisp.app.nostr.ClientMessage import com.wisp.app.nostr.Filter import com.wisp.app.nostr.LocalSigner +import com.wisp.app.nostr.Noffer import com.wisp.app.nostr.NostrEvent import com.wisp.app.nostr.NostrSigner import com.wisp.app.relay.RelayPool @@ -47,6 +48,9 @@ class ProfileViewModel(app: Application) : AndroidViewModel(app) { private val _lud16 = MutableStateFlow("") val lud16: StateFlow = _lud16 + private val _clinkOffer = MutableStateFlow("") + val clinkOffer: StateFlow = _clinkOffer + private val _publishing = MutableStateFlow(false) val publishing: StateFlow = _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(null) val uploading: StateFlow = _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 { diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt index 1aa20571..8010c3f3 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt @@ -8,6 +8,10 @@ import com.wisp.app.nostr.Filter import com.wisp.app.nostr.LocalSigner import com.wisp.app.nostr.Nip57 import com.wisp.app.nostr.Nip78 +import com.wisp.app.nostr.Noffer +import com.wisp.app.nostr.NofferData +import com.wisp.app.nostr.NofferException +import com.wisp.app.nostr.NofferPricing import com.wisp.app.nostr.NostrEvent import com.wisp.app.nostr.NostrSigner import com.wisp.app.nostr.toHex @@ -16,6 +20,7 @@ import com.wisp.app.repo.SigningMode import com.wisp.app.repo.EventRepository import com.wisp.app.repo.KeyRepository import com.wisp.app.repo.BalanceUnit +import com.wisp.app.repo.NofferClient import com.wisp.app.repo.NwcRepository import com.wisp.app.repo.SparkRepository import com.wisp.app.repo.WalletMode @@ -1070,6 +1075,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) { @@ -1092,12 +1114,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 { @@ -1137,6 +1170,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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 565d7414..a30ce792 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -749,7 +749,7 @@ Set up your Lightning Address Send Receive - Lightning address or invoice + Lightning address, invoice, or CLINK offer Paste Scan QR Gallery diff --git a/app/src/test/kotlin/com/wisp/app/nostr/NofferTest.kt b/app/src/test/kotlin/com/wisp/app/nostr/NofferTest.kt new file mode 100644 index 00000000..8ca4a469 --- /dev/null +++ b/app/src/test/kotlin/com/wisp/app/nostr/NofferTest.kt @@ -0,0 +1,122 @@ +package com.wisp.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>): String { + val bytes = mutableListOf() + 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")) + } +}