feat(bolt12): add editor to publish own kind:10058 BOLT12 offer list

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
Claude
2026-07-24 19:43:07 +00:00
parent c5d89ff7a0
commit 999bb14032
6 changed files with 363 additions and 0 deletions
@@ -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)
}
}
}
}
@@ -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<List<String>>(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()
}
}
}
@@ -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<Route.EditFavoriteAlgoFeeds> { FavoriteAlgoFeedsListScreen(accountViewModel, nav) }
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
composableFromEnd<Route.EditBolt12Offers> { Bolt12OffersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ContentDiscovery> { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) }
@@ -484,6 +484,8 @@ sealed class Route {
@Serializable object EditPaymentTargets : Route()
@Serializable object EditBolt12Offers : Route()
@Serializable object UpdateReactionType : Route()
@Serializable data class Nip47NWCSetup(
@@ -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),
+9
View File
@@ -1718,6 +1718,14 @@
<string name="no_payment_app_found_for_type">No app installed to handle %1$s payments. Please install a compatible wallet.</string>
<string name="error_dialog_payment_error">Unable to open payment</string>
<string name="bolt12_offers">BOLT12 Offers</string>
<string name="bolt12_offers_explainer">Publish reusable BOLT12 offers so others can zap you over Lightning without a separate invoice each time.</string>
<string name="bolt12_offers_section_explainer">Add one or more BOLT12 offers (lno1…). They are shared publicly so anyone can pay you.</string>
<string name="no_bolt12_offers_message">No BOLT12 offers set. Add one below ↓</string>
<string name="bolt12_offer">BOLT12 offer (lno1…)</string>
<string name="invalid_bolt12_offer">Not a valid BOLT12 offer</string>
<string name="delete_bolt12_offer">Delete BOLT12 offer</string>
<string name="uploading_state_ready">Not Started</string>
<string name="uploading_state_compressing">Compressing</string>
<string name="uploading_state_uploading">Uploading</string>
@@ -2155,6 +2163,7 @@
<string name="video_player_search_keywords" translatable="false">video, player, playback, autoplay, mute</string>
<string name="audio_visualizer_search_keywords" translatable="false">audio, visualizer, spectrum, bars, waves, radial, aurora, animation</string>
<string name="payment_targets_search_keywords" translatable="false">zap split, split, recipients, forward zaps</string>
<string name="bolt12_offers_search_keywords" translatable="false">bolt12, bolt 12, offer, lno, lightning, zap, reusable invoice</string>
<string name="call_settings_search_keywords" translatable="false">webrtc, video call, voice call, calls</string>
<string name="translations_search_keywords" translatable="false">language, translate, locale</string>
<string name="ots_explorer_search_keywords" translatable="false">opentimestamps, timestamp, ots, proof</string>