feat: add Nostr users to V4V splits by search, with avatar + name

The split editor previously only took raw, hand-typed Lightning addresses / node
pubkeys — no Nostr users, no avatars, no search. Bring it up to the Amethyst
standard used by zap-splits.

Adding a recipient now leads with a user search (reusing UserSuggestionState +
ShowUserSuggestionList): type a name or @handle, pick a person, and they're added
rendered with their avatar (BaseUserPicture) and display name (UsernameDisplay),
with their lud16 lightning address resolved automatically at save time. Picking a
user with no Lightning address is rejected with a toast. A manual "Add address"
fallback remains for raw destinations — a node pubkey for keysend, or a non-Nostr
lightning address — which keep the type toggle + text field.

Each recipient still shows its live percentage of the total weight and an
optional fee flag. (Recipients loaded from an existing value block arrive as raw
addresses, since the wire format stores only the Lightning destination.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
This commit is contained in:
Claude
2026-06-28 21:56:38 +00:00
parent cd73020dd8
commit 32ac685387
5 changed files with 209 additions and 46 deletions
@@ -170,7 +170,7 @@ fun EditPodcastShowScreen(
SwitchRow(stringRes(R.string.podcast_show_complete), vm.complete.value) { vm.complete.value = it }
SwitchRow(stringRes(R.string.podcast_show_locked), vm.locked.value) { vm.locked.value = it }
V4VSplitEditor(vm.splitEditor)
V4VSplitEditor(vm.splitEditor, accountViewModel)
}
}
}
@@ -252,7 +252,7 @@ fun NewPodcastEpisodeScreen(
)
// Episode-level V4V override; leave empty to inherit the show's split.
V4VSplitEditor(vm.splitEditor)
V4VSplitEditor(vm.splitEditor, accountViewModel)
}
if (vm.isEditing) {
@@ -24,9 +24,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilterChip
@@ -37,6 +40,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.draw.clip
@@ -46,18 +52,32 @@ import androidx.compose.ui.unit.dp
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.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.grayText
/**
* Editor for a Podcasting-2.0 value-for-value split: a card listing each recipient (name, lnaddress
* vs node toggle, address, weight, optional fee) plus an "Add recipient" action. Each recipient's
* share of incoming sats is shown live as a percentage of the total weight. Drives a
* [V4VSplitEditorState]; the owning composer reads [V4VSplitEditorState.toPodcastValue] on save.
* Editor for a Podcasting-2.0 value-for-value split. Recipients are added the Amethyst-native way —
* search for a Nostr user and they're rendered with avatar + name, their lightning address resolved
* automatically — with a manual "add address" fallback for raw lightning addresses or node keysend
* destinations. Each recipient's share of incoming sats is shown live as a percentage of the total
* weight. Drives a [V4VSplitEditorState]; the owning composer reads [V4VSplitEditorState.toPodcastValue].
*/
@Composable
fun V4VSplitEditor(state: V4VSplitEditorState) {
fun V4VSplitEditor(
state: V4VSplitEditorState,
accountViewModel: AccountViewModel,
) {
val total = state.totalSplit()
val userSuggestions =
remember { UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) }
var search by remember { mutableStateOf("") }
Column(
modifier =
@@ -92,27 +112,60 @@ fun V4VSplitEditor(state: V4VSplitEditorState) {
}
state.recipients.forEach { draft ->
RecipientCard(
draft = draft,
total = total,
onRemove = { state.remove(draft) },
if (draft.user.value != null) {
UserRecipientCard(draft, total, accountViewModel, onRemove = { state.remove(draft) })
} else {
ManualRecipientCard(draft, total, onRemove = { state.remove(draft) })
}
}
// Search a Nostr user to add (resolves their lightning address). Beautiful path.
OutlinedTextField(
value = search,
onValueChange = { newValue ->
search = newValue
if (newValue.length > 2) userSuggestions.processCurrentWord(newValue) else userSuggestions.reset()
},
label = { Text(stringRes(R.string.podcast_value_search_user)) },
placeholder = { Text(stringRes(R.string.podcast_value_search_user_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
if (search.length > 2) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
val added = state.addUser(user)
if (!added) {
accountViewModel.toastManager.toast(
R.string.podcast_value_for_value,
R.string.podcast_value_user_no_lnaddress,
)
}
search = ""
userSuggestions.reset()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
TextButton(onClick = { state.add() }, modifier = Modifier.fillMaxWidth()) {
// Fallback for raw destinations (a node pubkey for keysend, or a non-Nostr lightning address).
TextButton(onClick = { state.addManual() }, modifier = Modifier.fillMaxWidth()) {
Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Text(text = stringRes(R.string.podcast_value_add_recipient), modifier = Modifier.padding(start = 6.dp))
Text(text = stringRes(R.string.podcast_value_add_address), modifier = Modifier.padding(start = 6.dp))
}
}
}
@Composable
private fun RecipientCard(
draft: RecipientDraft,
private fun RecipientShell(
total: Int,
draft: RecipientDraft,
onRemove: () -> Unit,
content: @Composable RowScope.() -> Unit,
) {
val isNode by draft.isNode
val weight =
draft.split.value
.trim()
@@ -128,6 +181,75 @@ private fun RecipientCard(
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
content()
Text(
text = stringRes(R.string.podcast_value_split_percent, percent),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
IconButton(onClick = onRemove) {
Icon(
symbol = MaterialSymbols.Delete,
contentDescription = stringRes(R.string.podcast_value_remove_recipient),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
WeightAndFeeRow(draft, weight)
}
}
@Composable
private fun UserRecipientCard(
draft: RecipientDraft,
total: Int,
accountViewModel: AccountViewModel,
onRemove: () -> Unit,
) {
val user = draft.user.value ?: return
RecipientShell(total, draft, onRemove) {
BaseUserPicture(user, Size40dp, accountViewModel = accountViewModel)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
UsernameDisplay(user, accountViewModel = accountViewModel)
val lud = user.lnAddress()
Text(
text = lud ?: stringRes(R.string.podcast_value_user_no_lnaddress),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
)
}
}
}
@Composable
private fun ManualRecipientCard(
draft: RecipientDraft,
total: Int,
onRemove: () -> Unit,
) {
val isNode by draft.isNode
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val weight =
draft.split.value
.trim()
.toIntOrNull() ?: 0
val percent = if (total > 0 && weight > 0) weight * 100 / total else 0
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringRes(R.string.podcast_value_split_percent, percent),
@@ -170,15 +292,7 @@ private fun RecipientCard(
OutlinedTextField(
value = draft.address.value,
onValueChange = { draft.address.value = it },
label = {
Text(
if (isNode) {
stringRes(R.string.podcast_value_node_pubkey)
} else {
stringRes(R.string.podcast_value_lnaddress)
},
)
},
label = { Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey) else stringRes(R.string.podcast_value_lnaddress)) },
placeholder = {
Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey_hint) else stringRes(R.string.podcast_value_lnaddress_hint))
},
@@ -187,21 +301,29 @@ private fun RecipientCard(
isError = draft.address.value.isBlank(),
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = draft.split.value,
onValueChange = { input -> draft.split.value = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.podcast_value_weight)) },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = weight <= 0,
)
FilterChip(
selected = draft.fee.value,
onClick = { draft.fee.value = !draft.fee.value },
label = { Text(stringRes(R.string.podcast_value_fee)) },
)
}
WeightAndFeeRow(draft, weight)
}
}
@Composable
private fun WeightAndFeeRow(
draft: RecipientDraft,
weight: Int,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = draft.split.value,
onValueChange = { input -> draft.split.value = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.podcast_value_weight)) },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = weight <= 0,
)
FilterChip(
selected = draft.fee.value,
onClick = { draft.fee.value = !draft.fee.value },
label = { Text(stringRes(R.string.podcast_value_fee)) },
)
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.podcasts.PodcastValue
import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient
@@ -47,10 +48,22 @@ class V4VSplitEditorState {
value?.recipients?.forEach { recipients.add(RecipientDraft.from(it)) }
}
fun add() {
/** Add a blank row for a raw destination (a node keysend, or a non-Nostr lightning address). */
fun addManual() {
recipients.add(RecipientDraft())
}
/**
* Add a Nostr user as a recipient. Returns false (and adds nothing) if the user has no lightning
* address to pay — there's nothing to put in the value block. Duplicate users are ignored.
*/
fun addUser(user: User): Boolean {
if (user.lnAddress().isNullOrBlank()) return false
if (recipients.any { it.user.value?.pubkeyHex == user.pubkeyHex }) return true
recipients.add(RecipientDraft.forUser(user))
return true
}
fun remove(draft: RecipientDraft) {
recipients.remove(draft)
}
@@ -70,8 +83,15 @@ class V4VSplitEditorState {
}
}
/** One editable recipient row. All fields are Compose state so the editor recomposes as they change. */
/**
* One editable recipient row. Either backed by a Nostr [user] (rendered with avatar + name; its
* lightning address resolves at save time) or a raw destination typed by hand ([name]/[isNode]/
* [address]). All fields are Compose state so the editor recomposes as they change.
*/
class RecipientDraft {
/** When set, this row is a Nostr user — paid at their lud16, shown with avatar + name. */
val user = mutableStateOf<User?>(null)
val name = mutableStateOf("")
/** false = lnaddress (LNURL-pay), true = node (keysend to a raw node pubkey). */
@@ -80,12 +100,24 @@ class RecipientDraft {
val split = mutableStateOf("1")
val fee = mutableStateOf(false)
/** A recipient is payable once it has an address and a positive weight. */
/** A recipient is payable once it resolves to an address and has a positive weight. */
fun toRecipient(): PodcastValueRecipient? {
val addr = address.value.trim()
if (addr.isBlank()) return null
val weight = split.value.trim().toIntOrNull() ?: return null
if (weight <= 0) return null
user.value?.let { u ->
val lud = u.lnAddress()?.takeIf { it.isNotBlank() } ?: return null
return PodcastValueRecipient(
name = u.toBestDisplayName(),
type = PodcastValue.TYPE_LNADDRESS,
address = lud,
split = weight,
fee = if (fee.value) true else null,
)
}
val addr = address.value.trim()
if (addr.isBlank()) return null
return PodcastValueRecipient(
name = name.value.trim().ifBlank { null },
type = if (isNode.value) PodcastValue.TYPE_NODE else PodcastValue.TYPE_LNADDRESS,
@@ -96,6 +128,11 @@ class RecipientDraft {
}
companion object {
fun forUser(user: User): RecipientDraft =
RecipientDraft().apply {
this.user.value = user
}
fun from(recipient: PodcastValueRecipient): RecipientDraft =
RecipientDraft().apply {
name.value = recipient.name.orEmpty()
+4
View File
@@ -1006,6 +1006,10 @@
</plurals>
<string name="podcast_value_editor_hint">Add recipients to split incoming sats by weight. Listeners boost or stream value to these destinations.</string>
<string name="podcast_value_add_recipient">Add recipient</string>
<string name="podcast_value_add_address">Add address manually</string>
<string name="podcast_value_search_user">Add a Nostr user</string>
<string name="podcast_value_search_user_hint">Search by name or @handle</string>
<string name="podcast_value_user_no_lnaddress">This user has no Lightning address</string>
<string name="podcast_value_remove_recipient">Remove recipient</string>
<string name="podcast_value_recipient_name">Name (optional)</string>
<string name="podcast_value_type_lnaddress">Lightning address</string>