From 999bb14032102becafa5df48aec517128b648997 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:43:07 +0000 Subject: [PATCH] feat(bolt12): add editor to publish own kind:10058 BOLT12 offer list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Settings entry (mirroring the Payment Targets editor) that lets the logged-in user add/remove reusable BOLT12 offers (lno1…) and publishes them as a replaceable kind:10058 offer list. Offers are validated against Bolt12Bech32 before being accepted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../bolt12Offers/Bolt12OffersScreen.kt | 251 ++++++++++++++++++ .../bolt12Offers/Bolt12OffersViewModel.kt | 98 +++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 9 + 6 files changed, 363 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt new file mode 100644 index 0000000000..f482a2b626 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.bolt12Offers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun Bolt12OffersScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: Bolt12OffersViewModel = viewModel() + viewModel.init(accountViewModel) + + LaunchedEffect(key1 = accountViewModel) { + viewModel.load() + } + + Bolt12OffersScaffold(viewModel) { + nav.popBack() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Bolt12OffersScaffold( + viewModel: Bolt12OffersViewModel, + onClose: () -> Unit, +) { + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.bolt12_offers, + onCancel = { + viewModel.refresh() + onClose() + }, + onPost = { + viewModel.saveOffers() + onClose() + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding( + start = 16.dp, + top = padding.calculateTopPadding(), + end = 16.dp, + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringRes(id = R.string.bolt12_offers_explainer), + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 10.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.grayText, + ) + + Bolt12OffersBody(viewModel) + } + } +} + +@Composable +fun Bolt12OffersBody(viewModel: Bolt12OffersViewModel) { + val offers by viewModel.offers.collectAsStateWithLifecycle() + + LazyColumn( + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = FeedPadding, + ) { + item { + SettingsCategory( + R.string.bolt12_offers, + R.string.bolt12_offers_section_explainer, + SettingsCategoryFirstModifier, + ) + } + + if (offers.isEmpty()) { + item { + Text( + text = stringRes(id = R.string.no_bolt12_offers_message), + modifier = Modifier.padding(vertical = 16.dp), + ) + } + } else { + items(offers, key = { it }) { offer -> + Bolt12OfferEntry(offer = offer, onDelete = { viewModel.removeOffer(offer) }) + } + } + + item { + Spacer(modifier = StdVertSpacer) + Bolt12OfferAddField { raw -> viewModel.addOffer(raw) } + } + } +} + +@Composable +fun Bolt12OfferEntry( + offer: String, + onDelete: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround, + ) { + Text( + text = "${offer.take(14)}…${offer.takeLast(6)}", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDelete) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(id = R.string.delete_bolt12_offer), + ) + } + } +} + +@Composable +fun Bolt12OfferAddField(onAdd: (raw: String) -> Boolean) { + var offer by remember { mutableStateOf("") } + var isError by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(Size10dp)) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.bolt12_offer)) }, + modifier = Modifier.fillMaxWidth(), + value = offer, + onValueChange = { + offer = it + isError = false + }, + isError = isError, + supportingText = + if (isError) { + { Text(text = stringRes(R.string.invalid_bolt12_offer)) } + } else { + null + }, + placeholder = { + Text( + text = "lno1…", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, + ) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { + if (onAdd(offer)) { + offer = "" + isError = false + } else { + isError = true + } + }, + shape = ButtonBorder, + enabled = offer.isNotBlank(), + ) { + Text(text = stringRes(id = R.string.add), color = Color.White) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt new file mode 100644 index 0000000000..bc594dcbff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersViewModel.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.bolt12Offers + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Edits the logged-in user's NIP-XX BOLT12 offer list (kind 10058). Mirrors + * [com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsViewModel]; + * each entry is a canonical raw `lno1...` offer string. + */ +@Stable +class Bolt12OffersViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel + private lateinit var account: Account + + private val _offers = MutableStateFlow>(emptyList()) + val offers = _offers.asStateFlow() + private var isModified = false + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun load() { + refresh() + } + + fun refresh() { + isModified = false + viewModelScope.launch { + _offers.update { account.bolt12OfferList.flow.value } + } + } + + /** Returns the canonical offer if [raw] is a well-formed BOLT12 offer, else null. */ + fun canonicalOfferOrNull(raw: String): String? { + val canonical = Bolt12Bech32.canonicalize(raw) + return if (Bolt12Bech32.isOffer(canonical)) canonical else null + } + + /** Adds [raw] if it's a valid offer not already present; returns true when added. */ + fun addOffer(raw: String): Boolean { + val canonical = canonicalOfferOrNull(raw) ?: return false + if (_offers.value.contains(canonical)) return false + _offers.update { it.plus(canonical) } + isModified = true + return true + } + + fun removeOffer(offer: String) { + _offers.update { it.minus(offer) } + isModified = true + } + + fun saveOffers() { + if (isModified) { + accountViewModel.launchSigner { + saveOffersSuspend() + } + } + } + + suspend fun saveOffersSuspend() { + if (isModified) { + account.saveBolt12Offers(_offers.value) + refresh() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 6811206223..2a2b43ff4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.Disp import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen +import com.vitorpamplona.amethyst.ui.actions.bolt12Offers.Bolt12OffersScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.DisplayBlossomSyncProgress @@ -591,6 +592,7 @@ fun BuildNavigation( } composableFromEnd { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } + composableFromEnd { Bolt12OffersScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9401c1916d..fcd8179e4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -484,6 +484,8 @@ sealed class Route { @Serializable object EditPaymentTargets : Route() + @Serializable object EditBolt12Offers : Route() + @Serializable object UpdateReactionType : Route() @Serializable data class Nip47NWCSetup( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index b63e323eef..d6c3644a7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -74,6 +74,7 @@ fun buildSettingsCatalog( symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds), symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges), symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets), + symEntry(R.string.bolt12_offers, MaterialSymbols.Payment, R.string.bolt12_offers_search_keywords, Route.EditBolt12Offers), symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps), symEntry(R.string.relay_auth_settings_title, MaterialSymbols.Lock, R.string.relay_auth_search_keywords, Route.RelayAuthSettings), symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d820e271eb..bd5521bde7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1718,6 +1718,14 @@ No app installed to handle %1$s payments. Please install a compatible wallet. Unable to open payment + BOLT12 Offers + Publish reusable BOLT12 offers so others can zap you over Lightning without a separate invoice each time. + Add one or more BOLT12 offers (lno1…). They are shared publicly so anyone can pay you. + No BOLT12 offers set. Add one below ↓ + BOLT12 offer (lno1…) + Not a valid BOLT12 offer + Delete BOLT12 offer + Not Started Compressing Uploading @@ -2155,6 +2163,7 @@ video, player, playback, autoplay, mute audio, visualizer, spectrum, bars, waves, radial, aurora, animation zap split, split, recipients, forward zaps + bolt12, bolt 12, offer, lno, lightning, zap, reusable invoice webrtc, video call, voice call, calls language, translate, locale opentimestamps, timestamp, ots, proof