From 32ac6853872bfcef02e39b9eec9dc78dbba58b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 21:56:38 +0000 Subject: [PATCH] feat: add Nostr users to V4V splits by search, with avatar + name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../authoring/EditPodcastShowScreen.kt | 2 +- .../authoring/NewPodcastEpisodeScreen.kt | 2 +- .../podcasts/authoring/V4VSplitEditor.kt | 200 ++++++++++++++---- .../podcasts/authoring/V4VSplitEditorState.kt | 47 +++- amethyst/src/main/res/values/strings.xml | 4 + 5 files changed, 209 insertions(+), 46 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt index ebfa77b3bd..2917509791 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt index 9741a4f522..941a13c52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt index 6b8057c8f3..cf9f22fa05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt @@ -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)) }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt index 7177854346..99de92c0ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditorState.kt @@ -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(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() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 58d11807f3..d8b363279b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1006,6 +1006,10 @@ Add recipients to split incoming sats by weight. Listeners boost or stream value to these destinations. Add recipient + Add address manually + Add a Nostr user + Search by name or @handle + This user has no Lightning address Remove recipient Name (optional) Lightning address