mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+1
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+161
-39
@@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+42
-5
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user