From 1a66319a6dc90b91dba5c8b10a071c56a0a81cb2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 23 Mar 2023 12:24:46 -0400 Subject: [PATCH] Support to pledge additional bounty amounts. --- .../vitorpamplona/amethyst/model/Account.kt | 4 +- .../com/vitorpamplona/amethyst/model/Note.kt | 17 ++- .../amethyst/service/model/TextNoteEvent.kt | 13 +- .../amethyst/ui/note/AddBountyAmountDialog.kt | 144 ++++++++++++++++++ .../amethyst/ui/note/NoteCompose.kt | 39 +++-- .../amethyst/ui/screen/ThreadFeedView.kt | 2 +- app/src/main/res/values/strings.xml | 2 + 7 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/AddBountyAmountDialog.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7979d6adb..6980884ca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -357,7 +357,7 @@ class Account( } } - fun sendPost(message: String, replyTo: List?, mentions: List?) { + fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) { if (!isWriteable()) return val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } @@ -369,8 +369,10 @@ class Account( replyTos = repliesToHex, mentions = mentionsHex, addresses = addresses, + extraTags = tags, privateKey = loggedIn.privKey!! ) + Client.send(signedEvent) LocalCache.consume(signedEvent) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 4a29fd40b..1f23ebc1a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -67,7 +67,7 @@ open class Note(val idHex: String) { return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) } } - open fun address() = (event as? LongTextNoteEvent)?.address() + open fun address(): ATag? = null open fun createdAt() = event?.createdAt() @@ -235,6 +235,21 @@ open class Note(val idHex: String) { }.sumOf { it } } + fun hasPledgeBy(user: User): Boolean { + return replies + .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } + .any { + val pledgeValue = try { + BigDecimal(it.event?.content()) + } catch (e: Exception) { + null + // do nothing if it can't convert to bigdecimal + } + + pledgeValue != null && it.author != user + } + } + fun pledgedAmountByOthers(): BigDecimal { return replies .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt index fa0755ac8..ef349a792 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt @@ -18,7 +18,15 @@ class TextNoteEvent( companion object { const val kind = 1 - fun create(msg: String, replyTos: List?, mentions: List?, addresses: List?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent { + fun create( + msg: String, + replyTos: List?, + mentions: List?, + addresses: List?, + extraTags: List?, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): TextNoteEvent { val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val tags = mutableListOf>() replyTos?.forEach { @@ -33,6 +41,9 @@ class TextNoteEvent( findHashtags(msg).forEach { tags.add(listOf("t", it)) } + extraTags?.forEach { + tags.add(listOf("t", it)) + } val id = generateId(pubKey, createdAt, kind, tags, msg) val sig = Utils.sign(id, privateKey) return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/AddBountyAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/AddBountyAmountDialog.kt new file mode 100644 index 000000000..4791d00b4 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/AddBountyAmountDialog.kt @@ -0,0 +1,144 @@ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface +import androidx.compose.material.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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.actions.PostButton + +class AddBountyAmountViewModel : ViewModel() { + private var account: Account? = null + private var bounty: Note? = null + + var nextAmount by mutableStateOf(TextFieldValue("")) + + fun load(account: Account, bounty: Note?) { + this.account = account + this.bounty = bounty + } + + fun sendPost() { + val newValue = nextAmount.text.trim().toLongOrNull() + + if (newValue != null) { + account?.sendPost( + newValue.toString(), + listOfNotNull(bounty), + listOfNotNull(bounty?.author), + listOf("bounty-added-reward") + ) + + nextAmount = TextFieldValue("") + } + } + + fun cancel() { + nextAmount = TextFieldValue("") + } + + fun hasChanged(): Boolean { + return nextAmount.text.trim().toLongOrNull() != null + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AddBountyAmountDialog(bounty: Note, account: Account, onClose: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val postViewModel: AddBountyAmountViewModel = viewModel() + + LaunchedEffect(account) { + postViewModel.load(account, bounty) + } + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ) + ) { + Surface() { + Column(modifier = Modifier.padding(10.dp).width(IntrinsicSize.Min)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + CloseButton(onCancel = { + postViewModel.cancel() + onClose() + }) + + PostButton( + onPost = { + postViewModel.sendPost() + onClose() + }, + isActive = postViewModel.hasChanged() + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.pledge_amount_in_sats)) }, + value = postViewModel.nextAmount, + onValueChange = { + postViewModel.nextAmount = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number + ), + placeholder = { + Text( + text = "10000, 50000, 5000000", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + singleLine = true + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 46cd239ab..251282843 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -310,7 +310,7 @@ fun NoteCompose( val baseReward = noteEvent.getReward() if (baseReward != null) { - DisplayReward(baseReward, baseNote, navController) + DisplayReward(baseReward, baseNote, account, navController) } } } @@ -566,10 +566,16 @@ fun DisplayUncitedHashtags( fun DisplayReward( baseReward: BigDecimal, baseNote: Note, + account: Account, navController: NavController ) { + var popupExpanded by remember { mutableStateOf(false) } + Column() { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { popupExpanded = true } + ) { ClickableText( text = AnnotatedString("#bounty"), onClick = { navController.navigate("Hashtag/bounty") }, @@ -580,16 +586,25 @@ fun DisplayReward( ) ) - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp), - tint = BitcoinOrange - ) - val repliesState by baseNote.live().replies.observeAsState() val replyNote = repliesState?.note + if (replyNote?.hasPledgeBy(account.userProfile()) == true) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp), + tint = BitcoinOrange + ) + } else { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + var rewardAmount by remember { mutableStateOf( baseReward @@ -609,6 +624,12 @@ fun DisplayReward( color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } + + if (popupExpanded) { + AddBountyAmountDialog(baseNote, account) { + popupExpanded = false + } + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 138952bd9..3d5f67be7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -281,7 +281,7 @@ fun NoteMaster( val baseReward = noteEvent.getReward() if (baseReward != null) { - DisplayReward(baseReward, baseNote, navController) + DisplayReward(baseReward, baseNote, account, navController) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b11f58cd..901085b1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -260,4 +260,6 @@ Uses your private key to pay zaps without leaving the app. Anyone with access to your Nostr private key will be able to spend your wallet\'s balance. Only keep funds you are ok to lose and use a private relay if possible. The relay operator can see your payments metadata. Wallet Connect Pubkey Wallet Connect Relay + + Pledge Amount in Sats