From 20e76daff85466d4b21369064b2daf9bb3125a0d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 15 Sep 2023 12:56:24 -0400 Subject: [PATCH 1/6] Adds zap splits --- .../vitorpamplona/amethyst/model/Account.kt | 19 +-- .../service/lnurl/LightningAddressResolver.kt | 34 ++--- .../amethyst/ui/actions/NewPostView.kt | 52 +++++-- .../amethyst/ui/actions/NewPostViewModel.kt | 32 +++-- .../amethyst/ui/components/SplitItem.kt | 99 ++++++++++++++ .../amethyst/ui/note/NoteCompose.kt | 61 +++++++++ .../amethyst/ui/note/UserProfilePicture.kt | 26 ++++ .../ui/screen/loggedIn/AccountViewModel.kt | 128 +++++++++++++++--- app/src/main/res/values/strings.xml | 12 ++ .../vitorpamplona/amethyst/SplitterTest.kt | 89 ++++++++++++ .../quartz/events/ChannelMessageEvent.kt | 6 +- .../quartz/events/ChatMessageEvent.kt | 6 +- .../com/vitorpamplona/quartz/events/Event.kt | 22 ++- .../quartz/events/EventInterface.kt | 3 +- .../events/LiveActivitiesChatMessageEvent.kt | 6 +- .../quartz/events/LnZapRequestEvent.kt | 3 +- .../quartz/events/NIP24Factory.kt | 2 +- .../quartz/events/PollNoteEvent.kt | 6 +- .../quartz/events/PrivateDmEvent.kt | 6 +- .../quartz/events/TextNoteEvent.kt | 6 +- 20 files changed, 533 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt create mode 100644 app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.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 3efc40939..bcde2138b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -280,7 +280,7 @@ class Account( } } - fun createZapRequestFor(note: Note, pollOption: Int?, message: String = "", zapType: LnZapEvent.ZapType): LnZapRequestEvent? { + fun createZapRequestFor(note: Note, pollOption: Int?, message: String = "", zapType: LnZapEvent.ZapType, toUser: User?): LnZapRequestEvent? { if (!isWriteable()) return null note.event?.let { event -> @@ -291,7 +291,8 @@ class Account( keyPair.privKey!!, pollOption, message, - zapType + zapType, + toUser?.pubkeyHex ) } return null @@ -759,7 +760,7 @@ class Account( replyTo: List?, mentions: List?, tags: List? = null, - zapReceiver: String? = null, + zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, replyingTo: String?, @@ -820,7 +821,7 @@ class Account( valueMinimum: Int?, consensusThreshold: Int?, closedAt: Int?, - zapReceiver: String? = null, + zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, relayList: List? = null, @@ -864,7 +865,7 @@ class Account( } } - fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { + fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { if (!isWriteable()) return // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } @@ -886,7 +887,7 @@ class Account( LocalCache.consume(signedEvent, null) } - fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List?, mentions: List?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { + fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List?, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { if (!isWriteable()) return // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } @@ -908,11 +909,11 @@ class Account( LocalCache.consume(signedEvent, null) } - fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { + fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { sendPrivateMessage(message, toUser.pubkeyHex, replyingTo, mentions, zapReceiver, wantsToMarkAsSensitive, zapRaiserAmount, geohash) } - fun sendPrivateMessage(message: String, toUser: HexKey, replyingTo: Note? = null, mentions: List?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { + fun sendPrivateMessage(message: String, toUser: HexKey, replyingTo: Note? = null, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { if (!isWriteable()) return val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } @@ -941,7 +942,7 @@ class Account( subject: String? = null, replyingTo: Note? = null, mentions: List?, - zapReceiver: String? = null, + zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 00032ac22..9de5ea7ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -9,11 +9,9 @@ import com.vitorpamplona.quartz.encoders.Lud06 import com.vitorpamplona.quartz.encoders.toLnUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.Call -import okhttp3.Callback import okhttp3.Request -import okhttp3.Response import java.math.BigDecimal +import java.math.RoundingMode import java.net.URLEncoder class LightningAddressResolver() { @@ -62,7 +60,7 @@ class LightningAddressResolver() { } } - suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) { + suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) { val encodedMessage = URLEncoder.encode(message, "utf-8") val urlBinder = if (lnCallback.contains("?")) "&" else "?" @@ -78,22 +76,13 @@ class LightningAddressResolver() { .url(url) .build() - client.newCall(request).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - response.use { - if (it.isSuccessful) { - onSuccess(response.body.string()) - } else { - onError("Could not fetch invoice from $lnCallback") - } - } + client.newCall(request).execute().use { + if (it.isSuccessful) { + onSuccess(it.body.string()) + } else { + onError("Could not fetch invoice from $lnCallback") } - - override fun onFailure(call: Call, e: java.io.IOException) { - onError("Could not fetch an invoice from $lnCallback. Message ${e.message}") - e.printStackTrace() - } - }) + } } suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { @@ -111,7 +100,7 @@ class LightningAddressResolver() { milliSats: Long, message: String, nostrRequest: String? = null, - onSuccess: (String) -> Unit, + onSuccess: suspend (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit ) { @@ -155,13 +144,14 @@ class LightningAddressResolver() { lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr -> // Forces LN Invoice amount to be the requested amount. + val expectedAmountInSats = BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong() val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) - if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) { + if (invoiceAmount.toLong() == expectedAmountInSats) { onProgress(0.7f) onSuccess(pr) } else { onProgress(0.0f) - onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from server") + onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from ${lnaddress}. It should have been ${expectedAmountInSats}") } } ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason -> onProgress(0.0f) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index daf7d30dd..cf3193090 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -9,6 +9,7 @@ import android.util.Size import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -82,20 +83,24 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil import com.vitorpamplona.amethyst.service.noProtocolUrlValidator import com.vitorpamplona.amethyst.ui.components.* +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.CancelIcon import com.vitorpamplona.amethyst.ui.note.CloseIcon import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.PollIcon import com.vitorpamplona.amethyst.ui.note.RegularPostIcon +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -111,6 +116,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.lang.Math.round @Composable fun NewPostView( @@ -321,9 +327,9 @@ fun NewPostView( if (postViewModel.wantsForwardZapTo) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp) + modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp) ) { - FowardZapTo(postViewModel) + FowardZapTo(postViewModel, accountViewModel) } } @@ -721,7 +727,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) { } @Composable -fun FowardZapTo(postViewModel: NewPostViewModel) { +fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewModel) { Column( modifier = Modifier.fillMaxWidth() ) { @@ -755,7 +761,7 @@ fun FowardZapTo(postViewModel: NewPostViewModel) { } Text( - text = stringResource(R.string.zap_forward_title), + text = stringResource(R.string.zap_split_title), fontSize = 20.sp, fontWeight = FontWeight.W500, modifier = Modifier.padding(start = 10.dp) @@ -765,22 +771,52 @@ fun FowardZapTo(postViewModel: NewPostViewModel) { Divider() Text( - text = stringResource(R.string.zap_forward_explainer), + text = stringResource(R.string.zap_split_explainer), color = MaterialTheme.colors.placeholderText, modifier = Modifier.padding(vertical = 10.dp) ) + postViewModel.forwardZapTo.items.forEachIndexed { index, splitItem -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) { + BaseUserPicture(splitItem.key, Size55dp, accountViewModel = accountViewModel) + + Spacer(modifier = DoubleHorzSpacer) + + Column(modifier = Modifier.weight(1f)) { + UsernameDisplay(splitItem.key, showPlayButton = false) + Text( + text = String.format("%.0f%%", splitItem.percentage * 100), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + } + + Spacer(modifier = DoubleHorzSpacer) + + Slider( + value = splitItem.percentage, + onValueChange = { sliderValue -> + val rounded = (round(sliderValue * 20)) / 20.0f + postViewModel.updateZapPercentage(index, rounded) + }, + modifier = Modifier + .weight(1.5f) + ) + } + } + OutlinedTextField( value = postViewModel.forwardZapToEditting, onValueChange = { postViewModel.updateZapForwardTo(it) }, - - label = { Text(text = stringResource(R.string.zap_forward_lnAddress)) }, + label = { Text(text = stringResource(R.string.zap_split_serarch_and_add_user)) }, modifier = Modifier.fillMaxWidth(), placeholder = { Text( - text = stringResource(R.string.zap_forward_lnAddress), + text = stringResource(R.string.zap_split_serarch_and_add_user_placeholder), color = MaterialTheme.colors.placeholderText ) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 1bcbdecaf..1025f0a13 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -21,6 +21,7 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.noProtocolUrlValidator import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.MediaCompressor +import com.vitorpamplona.amethyst.ui.components.Split import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.AddressableEvent @@ -29,6 +30,7 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.ZapSplitSetup import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -88,9 +90,13 @@ open class NewPostViewModel() : ViewModel() { var canAddInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false) + data class ForwardZapSetup(val user: User) { + var percentage by mutableStateOf(100) + } + // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) - var forwardZapTo by mutableStateOf(null) + var forwardZapTo by mutableStateOf>(Split()) var forwardZapToEditting by mutableStateOf(TextFieldValue("")) // NSFW, Sensitive @@ -151,7 +157,7 @@ open class NewPostViewModel() : ViewModel() { wantsToAddGeoHash = false wantsZapraiser = false zapRaiserAmount = null - forwardZapTo = null + forwardZapTo = Split() forwardZapToEditting = TextFieldValue("") this.account = account @@ -166,10 +172,13 @@ open class NewPostViewModel() : ViewModel() { val dmUsers = toUsersTagger.mentions val zapReceiver = if (wantsForwardZapTo) { - if (forwardZapTo != null) { - forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06 - } else { - forwardZapToEditting.text + forwardZapTo?.items?.map { + ZapSplitSetup( + lnAddressOrPubKeyHex = it.key.pubkeyHex, + relay = it.key.relaysBeingUsed.keys.firstOrNull(), + weight = it.percentage.toDouble(), + isLnAddress = false + ) } } else { null @@ -355,7 +364,7 @@ open class NewPostViewModel() : ViewModel() { wantsForwardZapTo = false wantsToMarkAsSensitive = false wantsToAddGeoHash = false - forwardZapTo = null + forwardZapTo = Split() forwardZapToEditting = TextFieldValue("") userSuggestions = emptyList() @@ -460,6 +469,9 @@ open class NewPostViewModel() : ViewModel() { TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) ) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo?.addItem(item) + forwardZapToEditting = TextFieldValue("") + /* val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") val lastWordStart = it.end - lastWord.length val wordToInsert = "@${item.pubkeyNpub()}" @@ -468,7 +480,7 @@ open class NewPostViewModel() : ViewModel() { forwardZapToEditting = TextFieldValue( forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert), TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) - ) + )*/ } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { val lastWord = toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") val lastWordStart = it.end - lastWord.length @@ -656,6 +668,10 @@ open class NewPostViewModel() : ViewModel() { isValidvalueMaximum.value = true } } + + fun updateZapPercentage(index: Int, sliderValue: Float) { + forwardZapTo?.updatePercentage(index, sliderValue) + } } enum class GeohashPrecision(val digits: Int) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt new file mode 100644 index 000000000..a5569e45b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SplitItem.kt @@ -0,0 +1,99 @@ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlin.math.abs + +class SplitItem(val key: T) { + var percentage by mutableStateOf(0f) +} + +class Split() { + var items: List> by mutableStateOf(emptyList()) + + fun addItem(key: T): Int { + val wasEqualSplit = isEqualSplit() + val newItem = SplitItem(key) + items = items.plus(newItem) + + if (wasEqualSplit) { + forceEqualSplit() + } else { + updatePercentage(items.lastIndex, equalSplit()) + } + + return items.lastIndex + } + + fun equalSplit() = 1f / items.size + + fun isEqualSplit(): Boolean { + val expectedPercentage = equalSplit() + return items.all { (it.percentage - expectedPercentage) < 0.01 } + } + + fun forceEqualSplit() { + val correctPercentage = equalSplit() + items.forEach { + it.percentage = correctPercentage + } + } + + fun updatePercentage(index: Int, percentage: Float) { + if (items.isEmpty()) return + + val splitItem = items.getOrNull(index) ?: return + + if (items.size == 1) { + splitItem.percentage = 1f + } else { + splitItem.percentage = percentage + + println("Update ${items[index].key} to $percentage") + + val othersMustShare = 1.0f - splitItem.percentage + + val othersHave = items.sumOf { + if (it == splitItem) 0.0 else it.percentage.toDouble() + }.toFloat() + + if (abs(othersHave - othersMustShare) < 0.01) return // nothing to do + + println("Others Must Share $othersMustShare but have $othersHave") + + bottomUpAdjustment(othersMustShare, othersHave, index) + } + } + + private fun bottomUpAdjustment(othersMustShare: Float, othersHave: Float, exceptForIndex: Int) { + var needToRemove = othersHave - othersMustShare + if (needToRemove > 0) { + for (i in items.indices.reversed()) { + if (i == exceptForIndex) continue // do not update the current item + + if (needToRemove < items[i].percentage) { + val oldValue = items[i].percentage + items[i].percentage -= needToRemove + needToRemove = 0f + println("- Updating ${items[i].key} from $oldValue to ${items[i].percentage - needToRemove}. $needToRemove left") + } else { + val oldValue = items[i].percentage + needToRemove -= items[i].percentage + items[i].percentage = 0f + println("- Updating ${items[i].key} from $oldValue to ${0}. $needToRemove left") + } + + if (needToRemove < 0.01) { + break + } + } + } else if (needToRemove < 0) { + if (items.lastIndex == exceptForIndex) { + items[items.lastIndex - 1].percentage += -needToRemove + } else { + items.last().percentage += -needToRemove + } + } + } +} 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 0ce393707..2b1c2364e 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 @@ -31,11 +31,15 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Divider +import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.darkColors +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowForwardIos +import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.lightColors import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -117,11 +121,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier @@ -173,6 +179,7 @@ import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent import com.vitorpamplona.quartz.events.EmojiUrl +import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GenericRepostEvent @@ -1055,6 +1062,60 @@ private fun NoteBody( accountViewModel, nav ) + + val noteEvent = baseNote.event + val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false } + if (zapSplits && noteEvent != null) { + Spacer(modifier = HalfDoubleVertSpacer) + DisplayZapSplits(noteEvent, accountViewModel, nav) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DisplayZapSplits(noteEvent: EventInterface, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val list = remember(noteEvent) { noteEvent.zapSplitSetup() } + + Row(verticalAlignment = CenterVertically) { + Box( + Modifier + .height(20.dp) + .width(25.dp) + ) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier + .size(20.dp) + .align(Alignment.CenterStart), + tint = BitcoinOrange + ) + Icon( + imageVector = Icons.Outlined.ArrowForwardIos, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier + .size(13.dp) + .align(Alignment.CenterEnd), + tint = BitcoinOrange + ) + } + + Spacer(modifier = StdHorzSpacer) + + FlowRow { + list.forEach { + if (it.isLnAddress) { + ClickableText( + text = AnnotatedString(it.lnAddressOrPubKeyHex), + onClick = { }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) + ) + } else { + UserPicture(userHex = it.lnAddressOrPubKeyHex, size = 25.dp, accountViewModel = accountViewModel, nav = nav) + } + } + } + } } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 53fc06b6e..ea96a54aa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -102,6 +102,32 @@ fun DisplayBlankAuthor(size: Dp, modifier: Modifier = Modifier) { ) } +@Composable +fun UserPicture( + userHex: String, + size: Dp, + pictureModifier: Modifier = remember { Modifier }, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + LoadUser(baseUserHex = userHex) { + if (it != null) { + UserPicture( + user = it, + size = size, + pictureModifier = pictureModifier, + accountViewModel = accountViewModel, + nav = nav + ) + } else { + DisplayBlankAuthor( + size, + pictureModifier + ) + } + } +} + @Composable fun UserPicture( user: User, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index b105b5216..9bb07880b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.UserMetadata +import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -50,6 +51,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.math.BigDecimal import java.util.Locale +import kotlin.math.round @Stable class AccountViewModel(val account: Account) : ViewModel() { @@ -219,18 +221,120 @@ class AccountViewModel(val account: Account) : ViewModel() { } } - private suspend fun innerZap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { - val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() + private suspend fun innerZap(note: Note, amountMilliSats: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { + val zapSplitSetup = note.event?.zapSplitSetup() - if (lud16.isNullOrBlank()) { - onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)) - return + val zapsToSend = if (!zapSplitSetup.isNullOrEmpty()) { + zapSplitSetup + } else { + val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() + + if (lud16.isNullOrBlank()) { + onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)) + return + } + + listOf(ZapSplitSetup(lud16, null, weight = 1.0, true)) } + val totalWeight = zapsToSend.sumOf { it.weight } + + val invoicesToPayOnIntent = mutableListOf() + + zapsToSend.forEachIndexed { index, value -> + val outerProgressMin = index / zapsToSend.size.toFloat() + val outerProgressMax = (index + 1) / zapsToSend.size.toFloat() + + val zapValue = + round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000 + + if (value.isLnAddress) { + innerZap( + lud16 = value.lnAddressOrPubKeyHex, + note = note, + amount = zapValue, + pollOption = pollOption, + message = message, + context = context, + onError = onError, + onProgress = { + onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) + }, + zapType = zapType, + onPayInvoiceThroughIntent = { + invoicesToPayOnIntent.add(it) + } + ) + } else { + val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex) + val lud16 = user?.info?.lnAddress() + + if (lud16 != null) { + innerZap( + lud16 = lud16, + note = note, + amount = zapValue, + pollOption = pollOption, + message = message, + context = context, + onError = onError, + onProgress = { + onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) + }, + zapType = zapType, + overrideUser = user, + onPayInvoiceThroughIntent = { + invoicesToPayOnIntent.add(it) + } + ) + } else { + onError( + context.getString( + R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, + user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex + ) + ) + } + } + } + + if (invoicesToPayOnIntent.isNotEmpty()) { + payInvoices(bolt11s = invoicesToPayOnIntent, context = context) + } + + // Awaits for the event to come back to LocalCache. + viewModelScope.launch(Dispatchers.IO) { + delay(5000) + onProgress(1f) + } + } + + private suspend fun payInvoices(bolt11s: List, context: Context) { + val uri = "lightning:" + bolt11s.joinToString("&") + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + ContextCompat.startActivity(context, intent, null) + } + + private suspend fun innerZap( + lud16: String, + note: Note, + amount: Long, + pollOption: Int?, + message: String, + context: Context, + onError: (String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayInvoiceThroughIntent: (String) -> Unit, + zapType: LnZapEvent.ZapType, + overrideUser: User? = null + ) { var zapRequestJson = "" if (zapType != LnZapEvent.ZapType.NONZAP) { - val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType) + val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) if (zapRequest != null) { zapRequestJson = zapRequest.toJson() } @@ -263,19 +367,11 @@ class AccountViewModel(val account: Account) : ViewModel() { } ) onProgress(0.8f) - - // Awaits for the event to come back to LocalCache. - viewModelScope.launch(Dispatchers.IO) { - delay(5000) - onProgress(0f) - } } else { try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - ContextCompat.startActivity(context, intent, null) + onPayInvoiceThroughIntent(it) } catch (e: Exception) { - onError(context.getString(R.string.lightning_wallets_not_found)) + onError(context.getString(R.string.lightning_wallets_not_found2)) } onProgress(0f) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 33f12e712..620e0e43f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -570,4 +570,16 @@ Chats Global Search + + Split and Forward Zaps + Supporting clients will split and forward zaps to the users added here instead of yours + Search and Add User + "@User" + User %1$s does not have a lightning address set up to receive sats + Percentage + 25 + Splitting zaps with + Forwarding zaps to + + Lightning wallets not found diff --git a/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt new file mode 100644 index 000000000..a92ee91cb --- /dev/null +++ b/app/src/test/java/com/vitorpamplona/amethyst/SplitterTest.kt @@ -0,0 +1,89 @@ +package com.vitorpamplona.amethyst + +import com.vitorpamplona.amethyst.ui.components.Split +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SplitterTest { + @Test + fun testSplit() { + val mySplit = Split() + + val vitor = mySplit.addItem("Vitor") + + assertEquals(1f, mySplit.items[vitor].percentage, 0.01f) + assertTrue(mySplit.isEqualSplit()) + + val pablo = mySplit.addItem("Pablo") + + assertEquals(0.5f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f) + assertTrue(mySplit.isEqualSplit()) + + val gigi = mySplit.addItem("Gigi") + + assertEquals(0.33f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.33f, mySplit.items[gigi].percentage, 0.01f) + assertTrue(mySplit.isEqualSplit()) + + mySplit.updatePercentage(vitor, 0.5f) + + assertEquals(0.5f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.33f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.16f, mySplit.items[gigi].percentage, 0.01f) + assertFalse(mySplit.isEqualSplit()) + + mySplit.updatePercentage(vitor, 0.95f) + + assertEquals(0.95f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.0f, mySplit.items[gigi].percentage, 0.01f) + assertFalse(mySplit.isEqualSplit()) + + mySplit.updatePercentage(vitor, 0.15f) + + assertEquals(0.15f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.05f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.80f, mySplit.items[gigi].percentage, 0.01f) + assertFalse(mySplit.isEqualSplit()) + + mySplit.updatePercentage(pablo, 0.95f) + + assertEquals(0.05f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.95f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[gigi].percentage, 0.01f) + + mySplit.updatePercentage(gigi, 1f) + + assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f) + + mySplit.updatePercentage(vitor, 0.5f) + + assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f) + + mySplit.updatePercentage(pablo, 0.3f) + + assertEquals(0.50f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.30f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.20f, mySplit.items[gigi].percentage, 0.01f) + + mySplit.updatePercentage(gigi, 1f) + + assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.00f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(1.00f, mySplit.items[gigi].percentage, 0.01f) + + mySplit.updatePercentage(gigi, 0.5f) + + assertEquals(0.00f, mySplit.items[vitor].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[pablo].percentage, 0.01f) + assertEquals(0.50f, mySplit.items[gigi].percentage, 0.01f) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt index adbd79f0e..f8fb58a02 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt @@ -32,7 +32,7 @@ class ChannelMessageEvent( channel: String, replyTos: List? = null, mentions: List? = null, - zapReceiver: String?, + zapReceiver: List? = null, privateKey: ByteArray, createdAt: Long = TimeUtils.now(), markAsSensitive: Boolean, @@ -50,8 +50,8 @@ class ChannelMessageEvent( mentions?.forEach { tags.add(listOf("p", it)) } - zapReceiver?.let { - tags.add(listOf("zap", it)) + zapReceiver?.forEach { + tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) } if (markAsSensitive) { tags.add(listOf("content-warning", "")) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt index b41c60429..7940459f7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt @@ -57,7 +57,7 @@ class ChatMessageEvent( subject: String? = null, replyTos: List? = null, mentions: List? = null, - zapReceiver: String? = null, + zapReceiver: List? = null, markAsSensitive: Boolean = false, zapRaiserAmount: Long? = null, geohash: String? = null, @@ -75,8 +75,8 @@ class ChatMessageEvent( mentions?.forEach { tags.add(listOf("p", it, "", "mention")) } - zapReceiver?.let { - tags.add(listOf("zap", it)) + zapReceiver?.forEach { + tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) } if (markAsSensitive) { tags.add(listOf("content-warning", "")) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index ae78d7bc4..3ed0990cc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -93,7 +93,21 @@ open class Event( (it.size > 1 && it[0] == "zapraiser") }?.get(1)?.toLongOrNull() - override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1) + override fun hasZapSplitSetup() = tags.any { it.size > 1 && it[0] == "zap" } + + override fun zapSplitSetup(): List { + return tags.filter { it.size > 1 && it[0] == "zap" }.map { + val isLnAddress = it[0].contains("@") || it[0].startsWith("LNURL", true) + val weight = if (isLnAddress) 1.0 else (it.getOrNull(3)?.toDoubleOrNull() ?: 0.0) + + ZapSplitSetup( + it[1], + it.getOrNull(2), + weight, + isLnAddress + ) + } + } override fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { val aTagValue = it[1] @@ -421,3 +435,9 @@ fun String.bytesUsedInMemory(): Int { return (8 * ((((this.length) * 2) + 45) / 8)) } +data class ZapSplitSetup( + val lnAddressOrPubKeyHex: String, + val relay: String?, + val weight: Double, + val isLnAddress: Boolean, +) \ No newline at end of file diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index 64d308777..f7d0102bb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -58,7 +58,7 @@ interface EventInterface { fun getPoWRank(): Int fun getGeoHash(): String? - fun zapAddress(): String? + fun zapSplitSetup(): List fun isSensitive(): Boolean fun subject(): String? fun zapraiserAmount(): Long? @@ -78,4 +78,5 @@ interface EventInterface { fun taggedEmojis(): List fun matchTag1With(text: String): Boolean fun isExpired(): Boolean + fun hasZapSplitSetup(): Boolean } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt index 0974c5631..c98e84226 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt @@ -48,7 +48,7 @@ class LiveActivitiesChatMessageEvent( activity: ATag, replyTos: List? = null, mentions: List? = null, - zapReceiver: String?, + zapReceiver: List? = null, privateKey: ByteArray, createdAt: Long = TimeUtils.now(), markAsSensitive: Boolean, @@ -66,8 +66,8 @@ class LiveActivitiesChatMessageEvent( mentions?.forEach { tags.add(listOf("p", it)) } - zapReceiver?.let { - tags.add(listOf("zap", it)) + zapReceiver?.forEach { + tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) } if (markAsSensitive) { tags.add(listOf("content-warning", "")) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt index 50cbe743d..4c3149f6c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapRequestEvent.kt @@ -65,6 +65,7 @@ class LnZapRequestEvent( pollOption: Int?, message: String, zapType: LnZapEvent.ZapType, + toUserPubHex: String?, // Overrides in case of Zap Splits createdAt: Long = TimeUtils.now() ): LnZapRequestEvent { var content = message @@ -72,7 +73,7 @@ class LnZapRequestEvent( var pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey() var tags = listOf( listOf("e", originalNote.id()), - listOf("p", originalNote.pubKey()), + listOf("p", toUserPubHex ?: originalNote.pubKey()), listOf("relays") + relays ) if (originalNote is AddressableEvent) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt index cb7523351..6a5f12bc2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt @@ -12,7 +12,7 @@ class NIP24Factory { subject: String? = null, replyTos: List? = null, mentions: List? = null, - zapReceiver: String? = null, + zapReceiver: List? = null, markAsSensitive: Boolean = false, zapRaiserAmount: Long? = null, geohash: String? = null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt index cba6989f7..31d367d54 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt @@ -52,7 +52,7 @@ class PollNoteEvent( valueMinimum: Int?, consensusThreshold: Int?, closedAt: Int?, - zapReceiver: String?, + zapReceiver: List? = null, markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null @@ -76,8 +76,8 @@ class PollNoteEvent( tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString())) tags.add(listOf(CLOSED_AT, closedAt.toString())) - if (zapReceiver != null) { - tags.add(listOf("zap", zapReceiver)) + zapReceiver?.forEach { + tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) } if (markAsSensitive) { tags.add(listOf("content-warning", "")) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt index 5a0120ac4..722a87937 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt @@ -85,7 +85,7 @@ class PrivateDmEvent( msg: String, replyTos: List? = null, mentions: List? = null, - zapReceiver: String?, + zapReceiver: List? = null, privateKey: ByteArray, createdAt: Long = TimeUtils.now(), publishedRecipientPubKey: ByteArray? = null, @@ -110,8 +110,8 @@ class PrivateDmEvent( mentions?.forEach { tags.add(listOf("p", it)) } - zapReceiver?.let { - tags.add(listOf("zap", it)) + zapReceiver?.forEach { + tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) } if (markAsSensitive) { tags.add(listOf("content-warning", "")) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt index a24304603..184b15042 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -30,7 +30,7 @@ class TextNoteEvent( mentions: List?, addresses: List?, extraTags: List?, - zapReceiver: String?, + zapReceiver: List? = null, markAsSensitive: Boolean, zapRaiserAmount: Long?, replyingTo: String?, @@ -80,8 +80,8 @@ class TextNoteEvent( extraTags?.forEach { tags.add(listOf("t", it)) } - zapReceiver?.let { - tags.add(listOf("zap", it)) + zapReceiver?.forEach { + tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) } findURLs(msg).forEach { tags.add(listOf("r", it)) From 108ddffcc7d413895df73c850f7bad38e95b0569 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 15 Sep 2023 12:56:50 -0400 Subject: [PATCH 2/6] formatting --- .../amethyst/service/lnurl/LightningAddressResolver.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 9de5ea7ee..5e5fe1b6f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -151,7 +151,7 @@ class LightningAddressResolver() { onSuccess(pr) } else { onProgress(0.0f) - onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from ${lnaddress}. It should have been ${expectedAmountInSats}") + onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from $lnaddress. It should have been $expectedAmountInSats") } } ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason -> onProgress(0.0f) From 134e7cee0c34362bdc5765f24cc4f1b670c605eb Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Fri, 15 Sep 2023 16:59:32 +0000 Subject: [PATCH 3/6] New Crowdin translations by GitHub Action --- app/src/main/res/values-bn-rBD/strings.xml | 75 ++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index e25e22795..8565ac82c 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -204,6 +204,12 @@ নতুন অ্যাকাউন্ট যুক্ত করুন অ্যাকাউন্টগুলি অ্যাকাউন্ট নির্বাচন করুন + নতুন অ্যাকাউন্ট যুক্ত করুন + অ্যাকাউন্ট সক্রিয় করুন + ব্যক্তিগত চাবি আছে + শুধুমাত্র পাঠযোগ্য, কোনো ব্যক্তিগত চাবি নেই + পিছে যান + নির্বাচন করুন ব্রাউজারের লিংক শেয়ার করুন শেয়ার লেখকের আইডি @@ -254,6 +260,13 @@ একটি পোল পেশ করুন প্রয়োজনীয় ক্ষেত্রসমূহ: জ্যাপ প্রাপকেরা + পোলের প্রাথমিক বিবরণ… + বিকল্প %s + পোলের বিবিধ বিকল্পের বিবরণ + ঐচ্ছিক ক্ষেত্র: + ন্যূনতম জ্যাপ + সর্বোচ্চ জ্যাপ + ঐক্যমত (০–১০০)% এই সময় পর বন্ধ করুন দিনগুলি @@ -304,10 +317,72 @@ যাচাইযোগ্য Nostr.build (NIP-94) Nostr.build ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে যাচাইযোগ্য Nostrfiles.dev (NIP-94) + Nostrfiles.dev ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে + যাচাইযোগ্য Nostrcheck.me (NIP-94) + Nostrcheck.me ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে + আপনার রিলেগুলি (NIP-95) + ফাইলগুলো আপনার রিলে দ্বারা গৃহীত হয়। নতুন NIP: তারা এটি সমর্থন করে কিনা দেখে নিন + Tor/Orbot সেটআপ করুন + আপনার Orbot সেটআপের মাধ্যমে সংযুক্ত হোন + Orbot/Tor থেকে সংযোগ বিচ্ছিন্ন করতে চান? + আপনার ডেটা অবিলম্বে নিয়মিত নেটওয়ার্কে স্থানান্তর করা হবে + হ্যাঁ + না + অনুসরণ তালিকা + অনুসৃত সকলেরা + বৈশ্বিক + ## Tor এর মাধ্যমে Orbot এর সাথে সংযুক্ত হোন + \n\n১. [Orbot] নামান (https://play.google.com/store/apps/details?id=org.torproject.android) + \n২. Orbot চালু করুন + \n৩. Orbot এ ঢুকে Socks পোর্টটি খেয়াল করুন। এটি ৯০৫০ তে নির্দিষ্ট করা থাকে + \n৪. প্রয়োজনে Orbot এ পোর্টটি বদলে দিন + \n৫. স্ক্রিনের Socks পোর্টটি প্রস্তুত করুন + \n৬. Orbot কে প্রক্সি হিসেবে ব্যবহার করতে Activate বাটনটি চাপুন + + Orbot এর Socks পোর্ট + পোর্ট নম্বরটি অকার্যকর + Orbot ব্যবহার করুন + Tor/Orbot থেকে সংযোগ বিচ্ছিন্ন করুন + ব্যক্তিগত বার্তাসমূহ + যখন একটি ব্যক্তিগত বার্তা আসে তখন আপনাকে অবহিত করে + জ্যাপ গৃহীত হয়েছে + কেউ জ্যাপ পাঠালে আপনাকে অবহিত করে + %1$s স্যাট + %1$s থেকে + %1$s এর জন্য + অবহিত করুন: + আলোচনায় যুক্ত হোন + ব্যবহারকারী কিংবা গ্রুপের আইডি + npub, nevent অথবা hex + তৈরি করুন + যুক্ত হোন + আজ + আধেয় বিষয়ক সতর্কতা + এই পোস্টে সংবেদনশীল উপাদান রয়েছে যা কারো জন্য আপত্তিকর কিংবা সমস্যাজনক হতে পারে + সংবেদনশীল আধেয় সবসময় আড়ালে রাখুন + সংবেদনশীল বিষয়বস্তু সবসময় খোলামেলা দেখান + সবসময় আধেয় বিষয়ক সতর্কতা দেখান মালিক সংস্করণ সফটওয়্যার যোগাযোগ সমর্থিত NIPs প্রবেশমূল্য + সবসময় + শুধুমাত্র ওয়াইফাইতে + কখনো না + সিস্টেম + উজ্জ্বল + আঁধারি + অ্যাপ্লিকেশনের পছন্দসমূহ + ভাষা + থিম + ছবির পূর্বরূপ + ভিডিও প্লেব্যাক + URL-এর পূর্বরূপ + ছবিটি লোড করুন + স্প্যামাররা + মৌন করে রাখা। এটি তুলে নিতে ক্লিক করুন + শব্দ চালু আছে। মৌন করতে ক্লিক করুন + স্থানীয় এবং দূরবর্তী রেকর্ড অনুসন্ধান করুন From 20c2d19a9ca383e7cab0a05f019c4c3fed00605d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 15 Sep 2023 17:08:19 -0400 Subject: [PATCH 4/6] Refining the Payment via Intent --- .../amethyst/service/ZapPaymentHandler.kt | 207 ++++++++++++++++++ .../amethyst/ui/note/PollNote.kt | 26 ++- .../amethyst/ui/note/ReactionsRow.kt | 42 +++- .../amethyst/ui/note/ZapCustomDialog.kt | 132 +++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 162 +------------- app/src/main/res/values/strings.xml | 2 + 6 files changed, 410 insertions(+), 161 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt new file mode 100644 index 000000000..a0e0790d4 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -0,0 +1,207 @@ +package com.vitorpamplona.amethyst.service + +import android.content.Context +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.quartz.events.LnZapEvent +import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.events.ZapSplitSetup +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.round + +class ZapPaymentHandler(val account: Account) { + + @Immutable + data class Payable( + val info: ZapSplitSetup, + val user: User?, + val amountMilliSats: Long, + val invoice: String + ) + + suspend fun zap( + note: Note, + amountMilliSats: Long, + pollOption: Int?, + message: String, + context: Context, + onError: (String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, + zapType: LnZapEvent.ZapType + ) = withContext(Dispatchers.IO) { + val zapSplitSetup = note.event?.zapSplitSetup() + + val zapsToSend = if (!zapSplitSetup.isNullOrEmpty()) { + zapSplitSetup + } else { + val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() + + if (lud16.isNullOrBlank()) { + onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)) + return@withContext + } + + listOf(ZapSplitSetup(lud16, null, weight = 1.0, true)) + } + + val totalWeight = zapsToSend.sumOf { it.weight } + + val invoicesToPayOnIntent = mutableListOf() + + zapsToSend.forEachIndexed { index, value -> + val outerProgressMin = index / zapsToSend.size.toFloat() + val outerProgressMax = (index + 1) / zapsToSend.size.toFloat() + + val zapValue = + round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000 + + if (value.isLnAddress) { + innerZap( + lud16 = value.lnAddressOrPubKeyHex, + note = note, + amount = zapValue, + pollOption = pollOption, + message = message, + context = context, + onError = onError, + onProgress = { + onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) + }, + zapType = zapType, + onPayInvoiceThroughIntent = { + invoicesToPayOnIntent.add( + Payable( + info = value, + user = null, + amountMilliSats = zapValue, + invoice = it + ) + ) + } + ) + } else { + val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex) + val lud16 = user?.info?.lnAddress() + + if (lud16 != null) { + innerZap( + lud16 = lud16, + note = note, + amount = zapValue, + pollOption = pollOption, + message = message, + context = context, + onError = onError, + onProgress = { + onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) + }, + zapType = zapType, + overrideUser = user, + onPayInvoiceThroughIntent = { + invoicesToPayOnIntent.add( + Payable( + info = value, + user = user, + amountMilliSats = zapValue, + invoice = it + ) + ) + } + ) + } else { + onError( + context.getString( + R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, + user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex + ) + ) + } + } + } + + if (invoicesToPayOnIntent.isNotEmpty()) { + onPayViaIntent(invoicesToPayOnIntent.toImmutableList()) + onProgress(1f) + } else { + launch(Dispatchers.IO) { + // Awaits for the event to come back to LocalCache. + delay(5000) + onProgress(1f) + } + } + } + + private suspend fun innerZap( + lud16: String, + note: Note, + amount: Long, + pollOption: Int?, + message: String, + context: Context, + onError: (String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayInvoiceThroughIntent: (String) -> Unit, + zapType: LnZapEvent.ZapType, + overrideUser: User? = null + ) { + var zapRequestJson = "" + + if (zapType != LnZapEvent.ZapType.NONZAP) { + val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) + if (zapRequest != null) { + zapRequestJson = zapRequest.toJson() + } + } + + onProgress(0.10f) + + LightningAddressResolver().lnAddressInvoice( + lud16, + amount, + message, + zapRequestJson, + onSuccess = { + onProgress(0.7f) + if (account.hasWalletConnectSetup()) { + account.sendZapPaymentRequestFor( + bolt11 = it, + note, + onResponse = { response -> + if (response is PayInvoiceErrorResponse) { + onProgress(0.0f) + onError( + response.error?.message + ?: response.error?.code?.toString() + ?: "Error parsing error message" + ) + } else { + onProgress(1f) + } + } + ) + onProgress(0.8f) + } else { + try { + onPayInvoiceThroughIntent(it) + } catch (e: Exception) { + onError(context.getString(R.string.lightning_wallets_not_found2)) + } + onProgress(0f) + } + }, + onError = onError, + onProgress = onProgress + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 13253cfb2..1184fa6b2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.window.Popup import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -35,6 +36,8 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.* @@ -283,6 +286,12 @@ fun ZapVote( } var wantsToZap by remember { mutableStateOf(false) } + var wantsToPay by remember { + mutableStateOf>( + persistentListOf() + ) + } + var zappingProgress by remember { mutableStateOf(0f) } var showErrorMessageDialog by remember { mutableStateOf(null) } @@ -361,6 +370,9 @@ fun ZapVote( scope.launch(Dispatchers.Main) { zappingProgress = it } + }, + onPayViaIntent = { + }, zapType = accountViewModel.account.defaultZapType ) @@ -393,10 +405,19 @@ fun ZapVote( scope.launch(Dispatchers.Main) { zappingProgress = it } + }, + onPayViaIntent = { + wantsToPay = it } ) } + if (wantsToPay.isNotEmpty()) { + PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) { + wantsToPay = persistentListOf() + } + } + if (showErrorMessageDialog != null) { ErrorMessageDialog( title = stringResource(id = R.string.error_dialog_zap_error), @@ -463,7 +484,8 @@ fun FilteredZapAmountChoicePopup( onDismiss: () -> Unit, onChangeAmount: () -> Unit, onError: (text: String) -> Unit, - onProgress: (percent: Float) -> Unit + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit ) { val context = LocalContext.current @@ -502,6 +524,7 @@ fun FilteredZapAmountChoicePopup( context, onError, onProgress, + onPayViaIntent, defaultZapType ) onDismiss() @@ -526,6 +549,7 @@ fun FilteredZapAmountChoicePopup( context, onError, onProgress, + onPayViaIntent, defaultZapType ) onDismiss() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 6b3c2078a..7ee1a3027 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -78,6 +78,7 @@ import coil.request.CachePolicy import coil.request.ImageRequest import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.components.ImageUrlType import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer @@ -107,8 +108,11 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -907,6 +911,11 @@ fun ZapReaction( var wantsToChangeZapAmount by remember { mutableStateOf(false) } var wantsToSetCustomZap by remember { mutableStateOf(false) } var showErrorMessageDialog by remember { mutableStateOf(null) } + var wantsToPay by remember(baseNote) { + mutableStateOf>( + persistentListOf() + ) + } val context = LocalContext.current val scope = rememberCoroutineScope() @@ -938,6 +947,9 @@ fun ZapReaction( zappingProgress = 0f showErrorMessageDialog = it } + }, + onPayViaIntent = { + wantsToPay = it } ) }, @@ -971,6 +983,9 @@ fun ZapReaction( scope.launch(Dispatchers.Main) { zappingProgress = it } + }, + onPayViaIntent = { + wantsToPay = it } ) } @@ -981,7 +996,10 @@ fun ZapReaction( textContent = showErrorMessageDialog ?: "", onClickStartMessage = { baseNote.author?.let { - nav(routeToMessage(it, showErrorMessageDialog, accountViewModel)) + scope.launch(Dispatchers.IO) { + val route = routeToMessage(it, showErrorMessageDialog, accountViewModel) + nav(route) + } } }, onDismiss = { showErrorMessageDialog = null } @@ -995,6 +1013,12 @@ fun ZapReaction( ) } + if (wantsToPay.isNotEmpty()) { + PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) { + wantsToPay = persistentListOf() + } + } + if (wantsToSetCustomZap) { ZapCustomDialog( onClose = { wantsToSetCustomZap = false }, @@ -1009,6 +1033,9 @@ fun ZapReaction( zappingProgress = it } }, + onPayViaIntent = { + wantsToPay = it + }, accountViewModel = accountViewModel, baseNote = baseNote ) @@ -1045,7 +1072,8 @@ private fun zapClick( context: Context, onZappingProgress: (Float) -> Unit, onMultipleChoices: () -> Unit, - onError: (String) -> Unit + onError: (String) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit ) { if (accountViewModel.account.zapAmountChoices.isEmpty()) { scope.launch { @@ -1080,7 +1108,8 @@ private fun zapClick( onZappingProgress(it) } }, - zapType = accountViewModel.account.defaultZapType + zapType = accountViewModel.account.defaultZapType, + onPayViaIntent = onPayViaIntent ) } else if (accountViewModel.account.zapAmountChoices.size > 1) { onMultipleChoices() @@ -1391,7 +1420,8 @@ fun ZapAmountChoicePopup( onDismiss: () -> Unit, onChangeAmount: () -> Unit, onError: (text: String) -> Unit, - onProgress: (percent: Float) -> Unit + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit ) { val context = LocalContext.current @@ -1417,6 +1447,7 @@ fun ZapAmountChoicePopup( context, onError, onProgress, + onPayViaIntent, account.defaultZapType ) onDismiss() @@ -1441,7 +1472,8 @@ fun ZapAmountChoicePopup( context, onError, onProgress, - account.defaultZapType + onPayViaIntent, + account.defaultZapType, ) onDismiss() }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 828182dac..17b3d4ecc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -1,5 +1,7 @@ package com.vitorpamplona.amethyst.ui.note +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* @@ -10,25 +12,41 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver 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.service.NostrHomeDataSource +import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.actions.PostButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.LnZapEvent +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList class ZapOptionstViewModel : ViewModel() { @@ -62,6 +80,7 @@ fun ZapCustomDialog( onClose: () -> Unit, onError: (text: String) -> Unit, onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, accountViewModel: AccountViewModel, baseNote: Note ) { @@ -113,6 +132,7 @@ fun ZapCustomDialog( context, onError = onError, onProgress = onProgress, + onPayViaIntent = onPayViaIntent, zapType = selectedZapType ) onClose() @@ -266,3 +286,115 @@ fun ErrorMessageDialog( } ) } + +@Composable +fun PayViaIntentDialog( + payingInvoices: ImmutableList, + accountViewModel: AccountViewModel, + onClose: () -> Unit, +) { + val context = LocalContext.current + + Dialog( + onDismissRequest = onClose, + properties = DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ) + ) { + Surface() { + Column(modifier = Modifier.padding(10.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onPress = onClose) + } + + Spacer(modifier = DoubleVertSpacer) + + payingInvoices.forEachIndexed { index, it -> + val paid = remember { + mutableStateOf(false) + } + + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) { + if (it.user != null) { + BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel) + } else { + DisplayBlankAuthor(size = Size55dp) + } + + Spacer(modifier = DoubleHorzSpacer) + + Column(modifier = Modifier.weight(1f)) { + if (it.user != null) { + UsernameDisplay(it.user, showPlayButton = false) + } else { + Text( + text = stringResource(id = R.string.wallet_number, index+1), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + } + Row() { + Text( + text = showAmount((it.amountMilliSats/1000.0f).toBigDecimal()), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + Spacer(modifier = StdHorzSpacer) + Text( + text = stringResource(id = R.string.sats), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + } + + } + + Spacer(modifier = DoubleHorzSpacer) + + PayButton(isActive = !paid.value) { + paid.value = true + + val uri = "lightning:" + it.invoice + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + ContextCompat.startActivity(context, intent, null) + } + } + } + } + } + } +} + +@Composable +fun PayButton(isActive: Boolean, modifier: Modifier = Modifier, onPost: () -> Unit = {}) { + Button( + modifier = modifier, + onClick = { + onPost() + }, + shape = ButtonBorder, + colors = ButtonDefaults + .buttonColors( + backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray + ), + contentPadding = PaddingValues(0.dp) + ) { + if (isActive) + Text(text = stringResource(R.string.pay), color = Color.White) + else + Text(text = stringResource(R.string.paid), color = Color.White) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9bb07880b..f3639cfa2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.Nip05Verifier import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.service.OnlineChecker +import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification @@ -43,6 +44,7 @@ import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableSet @@ -215,111 +217,7 @@ class AccountViewModel(val account: Account) : ViewModel() { return null } - fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { - viewModelScope.launch(Dispatchers.IO) { - innerZap(note, amount, pollOption, message, context, onError, onProgress, zapType) - } - } - - private suspend fun innerZap(note: Note, amountMilliSats: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { - val zapSplitSetup = note.event?.zapSplitSetup() - - val zapsToSend = if (!zapSplitSetup.isNullOrEmpty()) { - zapSplitSetup - } else { - val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() - - if (lud16.isNullOrBlank()) { - onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)) - return - } - - listOf(ZapSplitSetup(lud16, null, weight = 1.0, true)) - } - - val totalWeight = zapsToSend.sumOf { it.weight } - - val invoicesToPayOnIntent = mutableListOf() - - zapsToSend.forEachIndexed { index, value -> - val outerProgressMin = index / zapsToSend.size.toFloat() - val outerProgressMax = (index + 1) / zapsToSend.size.toFloat() - - val zapValue = - round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000 - - if (value.isLnAddress) { - innerZap( - lud16 = value.lnAddressOrPubKeyHex, - note = note, - amount = zapValue, - pollOption = pollOption, - message = message, - context = context, - onError = onError, - onProgress = { - onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) - }, - zapType = zapType, - onPayInvoiceThroughIntent = { - invoicesToPayOnIntent.add(it) - } - ) - } else { - val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex) - val lud16 = user?.info?.lnAddress() - - if (lud16 != null) { - innerZap( - lud16 = lud16, - note = note, - amount = zapValue, - pollOption = pollOption, - message = message, - context = context, - onError = onError, - onProgress = { - onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin) - }, - zapType = zapType, - overrideUser = user, - onPayInvoiceThroughIntent = { - invoicesToPayOnIntent.add(it) - } - ) - } else { - onError( - context.getString( - R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, - user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex - ) - ) - } - } - } - - if (invoicesToPayOnIntent.isNotEmpty()) { - payInvoices(bolt11s = invoicesToPayOnIntent, context = context) - } - - // Awaits for the event to come back to LocalCache. - viewModelScope.launch(Dispatchers.IO) { - delay(5000) - onProgress(1f) - } - } - - private suspend fun payInvoices(bolt11s: List, context: Context) { - val uri = "lightning:" + bolt11s.joinToString("&") - - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - - ContextCompat.startActivity(context, intent, null) - } - - private suspend fun innerZap( - lud16: String, + fun zap( note: Note, amount: Long, pollOption: Int?, @@ -327,58 +225,12 @@ class AccountViewModel(val account: Account) : ViewModel() { context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, - onPayInvoiceThroughIntent: (String) -> Unit, - zapType: LnZapEvent.ZapType, - overrideUser: User? = null + onPayViaIntent: (ImmutableList) -> Unit, + zapType: LnZapEvent.ZapType ) { - var zapRequestJson = "" - - if (zapType != LnZapEvent.ZapType.NONZAP) { - val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) - if (zapRequest != null) { - zapRequestJson = zapRequest.toJson() - } + viewModelScope.launch(Dispatchers.IO) { + ZapPaymentHandler(account).zap(note, amount, pollOption, message, context, onError, onProgress, onPayViaIntent, zapType) } - - onProgress(0.10f) - - LightningAddressResolver().lnAddressInvoice( - lud16, - amount, - message, - zapRequestJson, - onSuccess = { - onProgress(0.7f) - if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor( - bolt11 = it, - note, - onResponse = { response -> - if (response is PayInvoiceErrorResponse) { - onProgress(0.0f) - onError( - response.error?.message - ?: response.error?.code?.toString() - ?: "Error parsing error message" - ) - } else { - onProgress(1f) - } - } - ) - onProgress(0.8f) - } else { - try { - onPayInvoiceThroughIntent(it) - } catch (e: Exception) { - onError(context.getString(R.string.lightning_wallets_not_found2)) - } - onProgress(0f) - } - }, - onError = onError, - onProgress = onProgress - ) } fun report(note: Note, type: ReportEvent.ReportType, content: String = "") { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 620e0e43f..6ee43e242 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -582,4 +582,6 @@ Forwarding zaps to Lightning wallets not found + Paid + Wallet %1$s From edd93141bc66f5bd84304bdc7b0d4810c872e3c9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 15 Sep 2023 17:13:35 -0400 Subject: [PATCH 5/6] No need for the @ when adding users to the Split --- .../amethyst/service/ZapPaymentHandler.kt | 2 +- .../amethyst/ui/actions/NewPostViewModel.kt | 4 ++-- .../amethyst/ui/note/PollNote.kt | 1 - .../amethyst/ui/note/ReactionsRow.kt | 3 +-- .../amethyst/ui/note/ZapCustomDialog.kt | 23 ++++++++----------- .../ui/screen/loggedIn/AccountViewModel.kt | 9 -------- app/src/main/res/values/strings.xml | 2 +- 7 files changed, 14 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index a0e0790d4..b58fefc98 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -204,4 +204,4 @@ class ZapPaymentHandler(val account: Account) { onProgress = onProgress ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 1025f0a13..165ebb50b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -436,10 +436,10 @@ open class NewPostViewModel() : ViewModel() { open fun updateZapForwardTo(it: TextFieldValue) { forwardZapToEditting = it if (it.selection.collapsed) { - val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWord = it.text userSuggestionAnchor = it.selection userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS - if (lastWord.startsWith("@") && lastWord.length > 2) { + if (lastWord.length > 2) { NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) viewModelScope.launch(Dispatchers.IO) { userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 1184fa6b2..8e4612345 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -372,7 +372,6 @@ fun ZapVote( } }, onPayViaIntent = { - }, zapType = accountViewModel.account.defaultZapType ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 7ee1a3027..ca6ada208 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -112,7 +112,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap -import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -1473,7 +1472,7 @@ fun ZapAmountChoicePopup( onError, onProgress, onPayViaIntent, - account.defaultZapType, + account.defaultZapType ) onDismiss() }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 17b3d4ecc..8743013a1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -25,17 +24,13 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver 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.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.actions.CloseButton -import com.vitorpamplona.amethyst.ui.actions.PostButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -291,7 +286,7 @@ fun ErrorMessageDialog( fun PayViaIntentDialog( payingInvoices: ImmutableList, accountViewModel: AccountViewModel, - onClose: () -> Unit, + onClose: () -> Unit ) { val context = LocalContext.current @@ -310,7 +305,7 @@ fun PayViaIntentDialog( ) { CloseButton(onPress = onClose) } - + Spacer(modifier = DoubleVertSpacer) payingInvoices.forEachIndexed { index, it -> @@ -321,7 +316,7 @@ fun PayViaIntentDialog( Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) { if (it.user != null) { BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel) - } else { + } else { DisplayBlankAuthor(size = Size55dp) } @@ -332,7 +327,7 @@ fun PayViaIntentDialog( UsernameDisplay(it.user, showPlayButton = false) } else { Text( - text = stringResource(id = R.string.wallet_number, index+1), + text = stringResource(id = R.string.wallet_number, index + 1), maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Bold, @@ -341,7 +336,7 @@ fun PayViaIntentDialog( } Row() { Text( - text = showAmount((it.amountMilliSats/1000.0f).toBigDecimal()), + text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()), maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Bold, @@ -356,7 +351,6 @@ fun PayViaIntentDialog( fontSize = 18.sp ) } - } Spacer(modifier = DoubleHorzSpacer) @@ -392,9 +386,10 @@ fun PayButton(isActive: Boolean, modifier: Modifier = Modifier, onPost: () -> Un ), contentPadding = PaddingValues(0.dp) ) { - if (isActive) + if (isActive) { Text(text = stringResource(R.string.pay), color = Color.White) - else + } else { Text(text = stringResource(R.string.paid), color = Color.White) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index f3639cfa2..78659d04b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1,18 +1,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.Context -import android.content.Intent -import android.net.Uri import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountState import com.vitorpamplona.amethyst.model.AddressableNote @@ -28,7 +24,6 @@ import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler -import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus @@ -38,22 +33,18 @@ import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent -import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.UserMetadata -import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.math.BigDecimal import java.util.Locale -import kotlin.math.round @Stable class AccountViewModel(val account: Account) : ViewModel() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6ee43e242..e794eb4e0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -574,7 +574,7 @@ Split and Forward Zaps Supporting clients will split and forward zaps to the users added here instead of yours Search and Add User - "@User" + Username or display name User %1$s does not have a lightning address set up to receive sats Percentage 25 From 536fb49bc37273dbe3c6973d23c4178f73720524 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 15 Sep 2023 17:15:54 -0400 Subject: [PATCH 6/6] 0.76.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index fe1949ff1..08b1ac8e3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 34 - versionCode 294 - versionName "0.76.0" + versionCode 295 + versionName "0.76.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables {