Merge branch 'main' into amber

This commit is contained in:
greenart7c3
2023-09-16 08:03:54 -03:00
committed by GitHub
26 changed files with 904 additions and 201 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 34 targetSdk 34
versionCode 294 versionCode 295
versionName "0.76.0" versionName "0.76.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -438,7 +438,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() && !loginWithAmber) return null if (!isWriteable() && !loginWithAmber) return null
note.event?.let { event -> note.event?.let { event ->
@@ -497,7 +497,8 @@ class Account(
keyPair.privKey!!, keyPair.privKey!!,
pollOption, pollOption,
message, message,
zapType zapType,
toUser?.pubkeyHex
) )
} }
} }
@@ -1223,7 +1224,7 @@ class Account(
replyTo: List<Note>?, replyTo: List<Note>?,
mentions: List<User>?, mentions: List<User>?,
tags: List<String>? = null, tags: List<String>? = null,
zapReceiver: String? = null, zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean, wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
replyingTo: String?, replyingTo: String?,
@@ -1293,7 +1294,7 @@ class Account(
valueMinimum: Int?, valueMinimum: Int?,
consensusThreshold: Int?, consensusThreshold: Int?,
closedAt: Int?, closedAt: Int?,
zapReceiver: String? = null, zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean, wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
relayList: List<Relay>? = null, relayList: List<Relay>? = null,
@@ -1348,17 +1349,8 @@ class Account(
} }
} }
fun sendChannelMessage( fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
message: String, if (!isWriteable()) return
toChannel: String,
replyTo: List<Note>?,
mentions: List<User>?,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null
) {
if (!isWriteable() && !loginWithAmber) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val repliesToHex = replyTo?.map { it.idHex } val repliesToHex = replyTo?.map { it.idHex }
@@ -1389,17 +1381,8 @@ class Account(
LocalCache.consume(signedEvent, null) LocalCache.consume(signedEvent, null)
} }
fun sendLiveMessage( fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
message: String, if (!isWriteable()) return
toChannel: ATag,
replyTo: List<Note>?,
mentions: List<User>?,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null
) {
if (!isWriteable() && !loginWithAmber) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val repliesToHex = replyTo?.map { it.idHex } val repliesToHex = replyTo?.map { it.idHex }
@@ -1430,21 +1413,12 @@ class Account(
LocalCache.consume(signedEvent, null) LocalCache.consume(signedEvent, null)
} }
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
sendPrivateMessage(message, toUser.pubkeyHex, replyingTo, mentions, zapReceiver, wantsToMarkAsSensitive, zapRaiserAmount, geohash) sendPrivateMessage(message, toUser.pubkeyHex, replyingTo, mentions, zapReceiver, wantsToMarkAsSensitive, zapRaiserAmount, geohash)
} }
fun sendPrivateMessage( fun sendPrivateMessage(message: String, toUser: HexKey, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
message: String, if (!isWriteable()) return
toUser: HexKey,
replyingTo: Note? = null,
mentions: List<User>?,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null
) {
if (!isWriteable() && !loginWithAmber) return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = mentions?.map { it.pubkeyHex } val mentionsHex = mentions?.map { it.pubkeyHex }
@@ -1491,7 +1465,7 @@ class Account(
subject: String? = null, subject: String? = null,
replyingTo: Note? = null, replyingTo: Note? = null,
mentions: List<User>?, mentions: List<User>?,
zapReceiver: String? = null, zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean, wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
geohash: String? = null geohash: String? = null
@@ -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<Payable>) -> 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<Payable>()
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
)
}
}
@@ -9,11 +9,9 @@ import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.toLnUrl import com.vitorpamplona.quartz.encoders.toLnUrl
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request import okhttp3.Request
import okhttp3.Response
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder import java.net.URLEncoder
class LightningAddressResolver() { 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 encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?" val urlBinder = if (lnCallback.contains("?")) "&" else "?"
@@ -78,24 +76,15 @@ class LightningAddressResolver() {
.url(url) .url(url)
.build() .build()
client.newCall(request).enqueue(object : Callback { client.newCall(request).execute().use {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) { if (it.isSuccessful) {
onSuccess(response.body.string()) onSuccess(it.body.string())
} else { } else {
onError("Could not fetch invoice from $lnCallback") 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) { suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fetchLightningAddressJson( fetchLightningAddressJson(
lnaddress, lnaddress,
@@ -111,7 +100,7 @@ class LightningAddressResolver() {
milliSats: Long, milliSats: Long,
message: String, message: String,
nostrRequest: String? = null, nostrRequest: String? = null,
onSuccess: (String) -> Unit, onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit onProgress: (percent: Float) -> Unit
) { ) {
@@ -155,13 +144,14 @@ class LightningAddressResolver() {
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr -> lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
// Forces LN Invoice amount to be the requested amount. // 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) val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) { if (invoiceAmount.toLong() == expectedAmountInSats) {
onProgress(0.7f) onProgress(0.7f)
onSuccess(pr) onSuccess(pr)
} else { } else {
onProgress(0.0f) 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 -> } ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason ->
onProgress(0.0f) onProgress(0.0f)
@@ -9,6 +9,7 @@ import android.util.Size
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn 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.ReverseGeoLocationUtil
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.* 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.CancelIcon
import com.vitorpamplona.amethyst.ui.note.CloseIcon import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.PollIcon import com.vitorpamplona.amethyst.ui.note.PollIcon
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder 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.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp 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.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -111,6 +116,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.lang.Math.round
@Composable @Composable
fun NewPostView( fun NewPostView(
@@ -321,9 +327,9 @@ fun NewPostView(
if (postViewModel.wantsForwardZapTo) { if (postViewModel.wantsForwardZapTo) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, 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 @Composable
fun FowardZapTo(postViewModel: NewPostViewModel) { fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewModel) {
Column( Column(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
@@ -755,7 +761,7 @@ fun FowardZapTo(postViewModel: NewPostViewModel) {
} }
Text( Text(
text = stringResource(R.string.zap_forward_title), text = stringResource(R.string.zap_split_title),
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.W500, fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp) modifier = Modifier.padding(start = 10.dp)
@@ -765,22 +771,52 @@ fun FowardZapTo(postViewModel: NewPostViewModel) {
Divider() Divider()
Text( Text(
text = stringResource(R.string.zap_forward_explainer), text = stringResource(R.string.zap_split_explainer),
color = MaterialTheme.colors.placeholderText, color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(vertical = 10.dp) 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( OutlinedTextField(
value = postViewModel.forwardZapToEditting, value = postViewModel.forwardZapToEditting,
onValueChange = { onValueChange = {
postViewModel.updateZapForwardTo(it) postViewModel.updateZapForwardTo(it)
}, },
label = { Text(text = stringResource(R.string.zap_split_serarch_and_add_user)) },
label = { Text(text = stringResource(R.string.zap_forward_lnAddress)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { placeholder = {
Text( Text(
text = stringResource(R.string.zap_forward_lnAddress), text = stringResource(R.string.zap_split_serarch_and_add_user_placeholder),
color = MaterialTheme.colors.placeholderText color = MaterialTheme.colors.placeholderText
) )
}, },
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.AddressableEvent
@@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -92,9 +94,13 @@ open class NewPostViewModel() : ViewModel() {
var canAddInvoice by mutableStateOf(false) var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false)
data class ForwardZapSetup(val user: User) {
var percentage by mutableStateOf(100)
}
// Forward Zap to // Forward Zap to
var wantsForwardZapTo by mutableStateOf(false) var wantsForwardZapTo by mutableStateOf(false)
var forwardZapTo by mutableStateOf<User?>(null) var forwardZapTo by mutableStateOf<Split<User>>(Split())
var forwardZapToEditting by mutableStateOf(TextFieldValue("")) var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
// NSFW, Sensitive // NSFW, Sensitive
@@ -155,7 +161,7 @@ open class NewPostViewModel() : ViewModel() {
wantsToAddGeoHash = false wantsToAddGeoHash = false
wantsZapraiser = false wantsZapraiser = false
zapRaiserAmount = null zapRaiserAmount = null
forwardZapTo = null forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("") forwardZapToEditting = TextFieldValue("")
this.account = account this.account = account
@@ -171,13 +177,13 @@ open class NewPostViewModel() : ViewModel() {
val dmUsers = toUsersTagger.mentions val dmUsers = toUsersTagger.mentions
val zapReceiver = if (wantsForwardZapTo) { val zapReceiver = if (wantsForwardZapTo) {
if (forwardZapTo != null) { forwardZapTo?.items?.map {
forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06 ZapSplitSetup(
} else { lnAddressOrPubKeyHex = it.key.pubkeyHex,
forwardZapToEditting.text relay = it.key.relaysBeingUsed.keys.firstOrNull(),
} weight = it.percentage.toDouble(),
} else { isLnAddress = false
null )
} }
val geoLocation = locUtil?.locationStateFlow?.value val geoLocation = locUtil?.locationStateFlow?.value
@@ -375,7 +381,7 @@ open class NewPostViewModel() : ViewModel() {
wantsForwardZapTo = false wantsForwardZapTo = false
wantsToMarkAsSensitive = false wantsToMarkAsSensitive = false
wantsToAddGeoHash = false wantsToAddGeoHash = false
forwardZapTo = null forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("") forwardZapToEditting = TextFieldValue("")
userSuggestions = emptyList() userSuggestions = emptyList()
@@ -447,10 +453,10 @@ open class NewPostViewModel() : ViewModel() {
open fun updateZapForwardTo(it: TextFieldValue) { open fun updateZapForwardTo(it: TextFieldValue) {
forwardZapToEditting = it forwardZapToEditting = it
if (it.selection.collapsed) { if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") val lastWord = it.text
userSuggestionAnchor = it.selection userSuggestionAnchor = it.selection
userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
if (lastWord.startsWith("@") && lastWord.length > 2) { if (lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
@@ -480,6 +486,9 @@ open class NewPostViewModel() : ViewModel() {
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
) )
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo?.addItem(item)
forwardZapToEditting = TextFieldValue("")
/*
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}" val wordToInsert = "@${item.pubkeyNpub()}"
@@ -488,7 +497,7 @@ open class NewPostViewModel() : ViewModel() {
forwardZapToEditting = TextFieldValue( forwardZapToEditting = TextFieldValue(
forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert), forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
) )*/
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") val lastWord = toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length val lastWordStart = it.end - lastWord.length
@@ -676,6 +685,10 @@ open class NewPostViewModel() : ViewModel() {
isValidvalueMaximum.value = true isValidvalueMaximum.value = true
} }
} }
fun updateZapPercentage(index: Int, sliderValue: Float) {
forwardZapTo?.updatePercentage(index, sliderValue)
}
} }
enum class GeohashPrecision(val digits: Int) { enum class GeohashPrecision(val digits: Int) {
@@ -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<T>(val key: T) {
var percentage by mutableStateOf(0f)
}
class Split<T>() {
var items: List<SplitItem<T>> 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
}
}
}
}
@@ -31,11 +31,15 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.darkColors 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.material.lightColors
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag 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.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font14SP 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.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier 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.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.EmojiUrl import com.vitorpamplona.quartz.events.EmojiUrl
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GenericRepostEvent
@@ -1055,6 +1062,60 @@ private fun NoteBody(
accountViewModel, accountViewModel,
nav 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 @Composable
@@ -24,6 +24,7 @@ import androidx.compose.ui.window.Popup
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange 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.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.*
@@ -283,6 +286,12 @@ fun ZapVote(
} }
var wantsToZap by remember { mutableStateOf(false) } var wantsToZap by remember { mutableStateOf(false) }
var wantsToPay by remember {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
persistentListOf()
)
}
var zappingProgress by remember { mutableStateOf(0f) } var zappingProgress by remember { mutableStateOf(0f) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) } var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
@@ -362,6 +371,8 @@ fun ZapVote(
zappingProgress = it zappingProgress = it
} }
}, },
onPayViaIntent = {
},
zapType = accountViewModel.account.defaultZapType zapType = accountViewModel.account.defaultZapType
) )
} else { } else {
@@ -393,10 +404,19 @@ fun ZapVote(
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
zappingProgress = it zappingProgress = it
} }
},
onPayViaIntent = {
wantsToPay = it
} }
) )
} }
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
wantsToPay = persistentListOf()
}
}
if (showErrorMessageDialog != null) { if (showErrorMessageDialog != null) {
ErrorMessageDialog( ErrorMessageDialog(
title = stringResource(id = R.string.error_dialog_zap_error), title = stringResource(id = R.string.error_dialog_zap_error),
@@ -463,7 +483,8 @@ fun FilteredZapAmountChoicePopup(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onChangeAmount: () -> Unit, onChangeAmount: () -> Unit,
onError: (text: String) -> Unit, onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -502,6 +523,7 @@ fun FilteredZapAmountChoicePopup(
context, context,
onError, onError,
onProgress, onProgress,
onPayViaIntent,
defaultZapType defaultZapType
) )
onDismiss() onDismiss()
@@ -526,6 +548,7 @@ fun FilteredZapAmountChoicePopup(
context, context,
onError, onError,
onProgress, onProgress,
onPayViaIntent,
defaultZapType defaultZapType
) )
onDismiss() onDismiss()
@@ -78,6 +78,7 @@ import coil.request.CachePolicy
import coil.request.ImageRequest import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ImageUrlType import com.vitorpamplona.amethyst.ui.components.ImageUrlType
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
@@ -107,6 +108,8 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter 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.toImmutableList
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -945,6 +948,11 @@ fun ZapReaction(
var wantsToChangeZapAmount by remember { mutableStateOf(false) } var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) } var wantsToSetCustomZap by remember { mutableStateOf(false) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) } var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
var wantsToPay by remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
persistentListOf()
)
}
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -976,6 +984,9 @@ fun ZapReaction(
zappingProgress = 0f zappingProgress = 0f
showErrorMessageDialog = it showErrorMessageDialog = it
} }
},
onPayViaIntent = {
wantsToPay = it
} }
) )
}, },
@@ -1009,6 +1020,9 @@ fun ZapReaction(
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
zappingProgress = it zappingProgress = it
} }
},
onPayViaIntent = {
wantsToPay = it
} }
) )
} }
@@ -1019,7 +1033,10 @@ fun ZapReaction(
textContent = showErrorMessageDialog ?: "", textContent = showErrorMessageDialog ?: "",
onClickStartMessage = { onClickStartMessage = {
baseNote.author?.let { baseNote.author?.let {
nav(routeToMessage(it, showErrorMessageDialog, accountViewModel)) scope.launch(Dispatchers.IO) {
val route = routeToMessage(it, showErrorMessageDialog, accountViewModel)
nav(route)
}
} }
}, },
onDismiss = { showErrorMessageDialog = null } onDismiss = { showErrorMessageDialog = null }
@@ -1033,6 +1050,12 @@ fun ZapReaction(
) )
} }
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
wantsToPay = persistentListOf()
}
}
if (wantsToSetCustomZap) { if (wantsToSetCustomZap) {
ZapCustomDialog( ZapCustomDialog(
onClose = { wantsToSetCustomZap = false }, onClose = { wantsToSetCustomZap = false },
@@ -1047,6 +1070,9 @@ fun ZapReaction(
zappingProgress = it zappingProgress = it
} }
}, },
onPayViaIntent = {
wantsToPay = it
},
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
baseNote = baseNote baseNote = baseNote
) )
@@ -1083,7 +1109,8 @@ private fun zapClick(
context: Context, context: Context,
onZappingProgress: (Float) -> Unit, onZappingProgress: (Float) -> Unit,
onMultipleChoices: () -> Unit, onMultipleChoices: () -> Unit,
onError: (String) -> Unit onError: (String) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
) { ) {
if (accountViewModel.account.zapAmountChoices.isEmpty()) { if (accountViewModel.account.zapAmountChoices.isEmpty()) {
scope.launch { scope.launch {
@@ -1118,7 +1145,8 @@ private fun zapClick(
onZappingProgress(it) onZappingProgress(it)
} }
}, },
zapType = accountViewModel.account.defaultZapType zapType = accountViewModel.account.defaultZapType,
onPayViaIntent = onPayViaIntent
) )
} else if (accountViewModel.account.zapAmountChoices.size > 1) { } else if (accountViewModel.account.zapAmountChoices.size > 1) {
onMultipleChoices() onMultipleChoices()
@@ -1434,7 +1462,8 @@ fun ZapAmountChoicePopup(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onChangeAmount: () -> Unit, onChangeAmount: () -> Unit,
onError: (text: String) -> Unit, onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -1460,6 +1489,7 @@ fun ZapAmountChoicePopup(
context, context,
onError, onError,
onProgress, onProgress,
onPayViaIntent,
account.defaultZapType account.defaultZapType
) )
onDismiss() onDismiss()
@@ -1484,6 +1514,7 @@ fun ZapAmountChoicePopup(
context, context,
onError, onError,
onProgress, onProgress,
onPayViaIntent,
account.defaultZapType account.defaultZapType
) )
onDismiss() onDismiss()
@@ -104,6 +104,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 @Composable
fun UserPicture( fun UserPicture(
user: User, user: User,
@@ -1,12 +1,8 @@
package com.vitorpamplona.amethyst.ui.note package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Arrangement import android.content.Intent
import androidx.compose.foundation.layout.Column import android.net.Uri
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.AlertDialog import androidx.compose.material.AlertDialog
import androidx.compose.material.Button import androidx.compose.material.Button
@@ -32,23 +28,34 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue 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.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder 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.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
class ZapOptionstViewModel : ViewModel() { class ZapOptionstViewModel : ViewModel() {
@@ -82,6 +89,7 @@ fun ZapCustomDialog(
onClose: () -> Unit, onClose: () -> Unit,
onError: (text: String) -> Unit, onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit, onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
baseNote: Note baseNote: Note
) { ) {
@@ -133,6 +141,7 @@ fun ZapCustomDialog(
context, context,
onError = onError, onError = onError,
onProgress = onProgress, onProgress = onProgress,
onPayViaIntent = onPayViaIntent,
zapType = selectedZapType zapType = selectedZapType
) )
} }
@@ -285,3 +294,115 @@ fun ErrorMessageDialog(
} }
) )
} }
@Composable
fun PayViaIntentDialog(
payingInvoices: ImmutableList<ZapPaymentHandler.Payable>,
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)
}
}
}
@@ -1,18 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
@@ -27,7 +23,7 @@ import com.vitorpamplona.amethyst.service.Nip05Verifier
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
@@ -37,16 +33,15 @@ import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale import java.util.Locale
@@ -225,87 +220,14 @@ class AccountViewModel(val account: Account) : ViewModel() {
context: Context, context: Context,
onError: (String) -> Unit, onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit, onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
zapType: LnZapEvent.ZapType zapType: LnZapEvent.ZapType
) { ) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
innerZap(note, amount, pollOption, message, context, onError, onProgress, zapType) ZapPaymentHandler(account).zap(note, amount, pollOption, message, context, onError, onProgress, onPayViaIntent, zapType)
} }
} }
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()
if (lud16.isNullOrBlank()) {
onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats))
return
}
var zapRequestJson = ""
if (zapType != LnZapEvent.ZapType.NONZAP) {
val localZapRequest = account.createZapRequestFor(note, pollOption, message, zapType)
if (localZapRequest != null) {
zapRequestJson = localZapRequest.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)
// 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)
} catch (e: Exception) {
onError(context.getString(R.string.lightning_wallets_not_found))
}
onProgress(0f)
}
},
onError = onError,
onProgress = onProgress
)
}
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") { fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
account.report(note, type, content) account.report(note, type, content)
} }
@@ -204,6 +204,12 @@
<string name="account_switch_add_account_dialog_title">নতুন অ্যাকাউন্ট যুক্ত করুন</string> <string name="account_switch_add_account_dialog_title">নতুন অ্যাকাউন্ট যুক্ত করুন</string>
<string name="drawer_accounts">অ্যাকাউন্টগুলি</string> <string name="drawer_accounts">অ্যাকাউন্টগুলি</string>
<string name="account_switch_select_account">অ্যাকাউন্ট নির্বাচন করুন</string> <string name="account_switch_select_account">অ্যাকাউন্ট নির্বাচন করুন</string>
<string name="account_switch_add_account_btn">নতুন অ্যাকাউন্ট যুক্ত করুন</string>
<string name="account_switch_active_account">অ্যাকাউন্ট সক্রিয় করুন</string>
<string name="account_switch_has_private_key">ব্যক্তিগত চাবি আছে</string>
<string name="account_switch_pubkey_only">শুধুমাত্র পাঠযোগ্য, কোনো ব্যক্তিগত চাবি নেই</string>
<string name="back">পিছে যান</string>
<string name="quick_action_select">নির্বাচন করুন</string>
<string name="quick_action_share_browser_link">ব্রাউজারের লিংক শেয়ার করুন</string> <string name="quick_action_share_browser_link">ব্রাউজারের লিংক শেয়ার করুন</string>
<string name="quick_action_share">শেয়ার</string> <string name="quick_action_share">শেয়ার</string>
<string name="quick_action_copy_user_id">লেখকের আইডি</string> <string name="quick_action_copy_user_id">লেখকের আইডি</string>
@@ -254,6 +260,13 @@
<string name="post_poll">একটি পোল পেশ করুন</string> <string name="post_poll">একটি পোল পেশ করুন</string>
<string name="poll_heading_required">প্রয়োজনীয় ক্ষেত্রসমূহ:</string> <string name="poll_heading_required">প্রয়োজনীয় ক্ষেত্রসমূহ:</string>
<string name="poll_zap_recipients">জ্যাপ প্রাপকেরা</string> <string name="poll_zap_recipients">জ্যাপ প্রাপকেরা</string>
<string name="poll_primary_description">পোলের প্রাথমিক বিবরণ…</string>
<string name="poll_option_index">বিকল্প %s</string>
<string name="poll_option_description">পোলের বিবিধ বিকল্পের বিবরণ</string>
<string name="poll_heading_optional">ঐচ্ছিক ক্ষেত্র:</string>
<string name="poll_zap_value_min">ন্যূনতম জ্যাপ</string>
<string name="poll_zap_value_max">সর্বোচ্চ জ্যাপ</string>
<string name="poll_consensus_threshold">ঐক্যমত</string>
<string name="poll_consensus_threshold_percent">(০–১০০)%</string> <string name="poll_consensus_threshold_percent">(০–১০০)%</string>
<string name="poll_closing_time">এই সময় পর বন্ধ করুন</string> <string name="poll_closing_time">এই সময় পর বন্ধ করুন</string>
<string name="poll_closing_time_days">দিনগুলি</string> <string name="poll_closing_time_days">দিনগুলি</string>
@@ -304,10 +317,72 @@
<string name="upload_server_nostrbuild_nip94">যাচাইযোগ্য Nostr.build (NIP-94)</string> <string name="upload_server_nostrbuild_nip94">যাচাইযোগ্য Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Nostr.build ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে</string> <string name="upload_server_nostrbuild_nip94_explainer">Nostr.build ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে</string>
<string name="upload_server_nostrfilesdev_nip94">যাচাইযোগ্য Nostrfiles.dev (NIP-94)</string> <string name="upload_server_nostrfilesdev_nip94">যাচাইযোগ্য Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Nostrfiles.dev ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে</string>
<string name="upload_server_nostrcheckme_nip94">যাচাইযোগ্য Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Nostrcheck.me ফাইলটি পরিবর্তন করেছে কিনা তা পরীক্ষা করে। নতুন NIP: অন্য ক্লায়েন্টরা এটি দেখতে নাও পেতে পারে</string>
<string name="upload_server_relays_nip95">আপনার রিলেগুলি (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">ফাইলগুলো আপনার রিলে দ্বারা গৃহীত হয়। নতুন NIP: তারা এটি সমর্থন করে কিনা দেখে নিন</string>
<string name="connect_via_tor_short">Tor/Orbot সেটআপ করুন</string>
<string name="connect_via_tor">আপনার Orbot সেটআপের মাধ্যমে সংযুক্ত হোন</string>
<string name="do_you_really_want_to_disable_tor_title">Orbot/Tor থেকে সংযোগ বিচ্ছিন্ন করতে চান?</string>
<string name="do_you_really_want_to_disable_tor_text">আপনার ডেটা অবিলম্বে নিয়মিত নেটওয়ার্কে স্থানান্তর করা হবে</string>
<string name="yes">হ্যাঁ</string>
<string name="no">না</string>
<string name="follow_list_selection">অনুসরণ তালিকা</string>
<string name="follow_list_kind3follows">অনুসৃত সকলেরা</string>
<string name="follow_list_global">বৈশ্বিক</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 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 বাটনটি চাপুন
</string>
<string name="orbot_socks_port">Orbot এর Socks পোর্ট</string>
<string name="invalid_port_number">পোর্ট নম্বরটি অকার্যকর</string>
<string name="use_orbot">Orbot ব্যবহার করুন</string>
<string name="disconnect_from_your_orbot_setup">Tor/Orbot থেকে সংযোগ বিচ্ছিন্ন করুন</string>
<string name="app_notification_dms_channel_name">ব্যক্তিগত বার্তাসমূহ</string>
<string name="app_notification_dms_channel_description">যখন একটি ব্যক্তিগত বার্তা আসে তখন আপনাকে অবহিত করে</string>
<string name="app_notification_zaps_channel_name">জ্যাপ গৃহীত হয়েছে</string>
<string name="app_notification_zaps_channel_description">কেউ জ্যাপ পাঠালে আপনাকে অবহিত করে</string>
<string name="app_notification_zaps_channel_message">%1$s স্যাট</string>
<string name="app_notification_zaps_channel_message_from">%1$s থেকে</string>
<string name="app_notification_zaps_channel_message_for">%1$s এর জন্য</string>
<string name="reply_notify">অবহিত করুন: </string>
<string name="channel_list_join_conversation">আলোচনায় যুক্ত হোন</string>
<string name="channel_list_user_or_group_id">ব্যবহারকারী কিংবা গ্রুপের আইডি</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent অথবা hex</string>
<string name="channel_list_create_channel">তৈরি করুন</string>
<string name="channel_list_join_channel">যুক্ত হোন</string>
<string name="today">আজ</string>
<string name="content_warning">আধেয় বিষয়ক সতর্কতা</string>
<string name="content_warning_explanation">এই পোস্টে সংবেদনশীল উপাদান রয়েছে যা কারো জন্য আপত্তিকর কিংবা সমস্যাজনক হতে পারে</string>
<string name="content_warning_hide_all_sensitive_content">সংবেদনশীল আধেয় সবসময় আড়ালে রাখুন</string>
<string name="content_warning_show_all_sensitive_content">সংবেদনশীল বিষয়বস্তু সবসময় খোলামেলা দেখান</string>
<string name="content_warning_see_warnings">সবসময় আধেয় বিষয়ক সতর্কতা দেখান</string>
<string name="owner">মালিক</string> <string name="owner">মালিক</string>
<string name="version">সংস্করণ</string> <string name="version">সংস্করণ</string>
<string name="software">সফটওয়্যার</string> <string name="software">সফটওয়্যার</string>
<string name="contact">যোগাযোগ</string> <string name="contact">যোগাযোগ</string>
<string name="supports">সমর্থিত NIPs</string> <string name="supports">সমর্থিত NIPs</string>
<string name="admission_fees">প্রবেশমূল্য</string> <string name="admission_fees">প্রবেশমূল্য</string>
<string name="connectivity_type_always">সবসময়</string>
<string name="connectivity_type_wifi_only">শুধুমাত্র ওয়াইফাইতে</string>
<string name="connectivity_type_never">কখনো না</string>
<string name="system">সিস্টেম</string>
<string name="light">উজ্জ্বল</string>
<string name="dark">আঁধারি</string>
<string name="application_preferences">অ্যাপ্লিকেশনের পছন্দসমূহ</string>
<string name="language">ভাষা</string>
<string name="theme">থিম</string>
<string name="automatically_load_images_gifs">ছবির পূর্বরূপ</string>
<string name="automatically_play_videos">ভিডিও প্লেব্যাক</string>
<string name="automatically_show_url_preview">URL-এর পূর্বরূপ</string>
<string name="load_image">ছবিটি লোড করুন</string>
<string name="spamming_users">স্প্যামাররা</string>
<string name="muted_button">মৌন করে রাখা। এটি তুলে নিতে ক্লিক করুন</string>
<string name="mute_button">শব্দ চালু আছে। মৌন করতে ক্লিক করুন</string>
<string name="search_button">স্থানীয় এবং দূরবর্তী রেকর্ড অনুসন্ধান করুন</string>
</resources> </resources>
+14
View File
@@ -574,4 +574,18 @@
<string name="active_for_chats">Chats</string> <string name="active_for_chats">Chats</string>
<string name="active_for_global">Global</string> <string name="active_for_global">Global</string>
<string name="active_for_search">Search</string> <string name="active_for_search">Search</string>
<string name="zap_split_title">Split and Forward Zaps</string>
<string name="zap_split_explainer">Supporting clients will split and forward zaps to the users added here instead of yours</string>
<string name="zap_split_serarch_and_add_user">Search and Add User</string>
<string name="zap_split_serarch_and_add_user_placeholder">Username or display name</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">User %1$s does not have a lightning address set up to receive sats</string>
<string name="zap_split_weight">Percentage</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Splitting zaps with</string>
<string name="forwarding_zaps_to">Forwarding zaps to</string>
<string name="lightning_wallets_not_found2">Lightning wallets not found</string>
<string name="paid">Paid</string>
<string name="wallet_number">Wallet %1$s</string>
</resources> </resources>
@@ -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<String>()
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)
}
}
@@ -33,7 +33,7 @@ class ChannelMessageEvent(
channel: String, channel: String,
replyTos: List<String>? = null, replyTos: List<String>? = null,
mentions: List<String>? = null, mentions: List<String>? = null,
zapReceiver: String?, zapReceiver: List<ZapSplitSetup>? = null,
keyPair: KeyPair, keyPair: KeyPair,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
markAsSensitive: Boolean, markAsSensitive: Boolean,
@@ -50,8 +50,8 @@ class ChannelMessageEvent(
mentions?.forEach { mentions?.forEach {
tags.add(listOf("p", it)) tags.add(listOf("p", it))
} }
zapReceiver?.let { zapReceiver?.forEach {
tags.add(listOf("zap", it)) tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
} }
if (markAsSensitive) { if (markAsSensitive) {
tags.add(listOf("content-warning", "")) tags.add(listOf("content-warning", ""))
@@ -58,7 +58,7 @@ class ChatMessageEvent(
subject: String? = null, subject: String? = null,
replyTos: List<String>? = null, replyTos: List<String>? = null,
mentions: List<String>? = null, mentions: List<String>? = null,
zapReceiver: String? = null, zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false, markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
geohash: String? = null, geohash: String? = null,
@@ -76,8 +76,8 @@ class ChatMessageEvent(
mentions?.forEach { mentions?.forEach {
tags.add(listOf("p", it, "", "mention")) tags.add(listOf("p", it, "", "mention"))
} }
zapReceiver?.let { zapReceiver?.forEach {
tags.add(listOf("zap", it)) tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
} }
if (markAsSensitive) { if (markAsSensitive) {
tags.add(listOf("content-warning", "")) tags.add(listOf("content-warning", ""))
@@ -93,7 +93,21 @@ open class Event(
(it.size > 1 && it[0] == "zapraiser") (it.size > 1 && it[0] == "zapraiser")
}?.get(1)?.toLongOrNull() }?.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<ZapSplitSetup> {
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 { override fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTagValue = it[1] val aTagValue = it[1]
@@ -421,3 +435,9 @@ fun String.bytesUsedInMemory(): Int {
return (8 * ((((this.length) * 2) + 45) / 8)) return (8 * ((((this.length) * 2) + 45) / 8))
} }
data class ZapSplitSetup(
val lnAddressOrPubKeyHex: String,
val relay: String?,
val weight: Double,
val isLnAddress: Boolean,
)
@@ -58,7 +58,7 @@ interface EventInterface {
fun getPoWRank(): Int fun getPoWRank(): Int
fun getGeoHash(): String? fun getGeoHash(): String?
fun zapAddress(): String? fun zapSplitSetup(): List<ZapSplitSetup>
fun isSensitive(): Boolean fun isSensitive(): Boolean
fun subject(): String? fun subject(): String?
fun zapraiserAmount(): Long? fun zapraiserAmount(): Long?
@@ -78,4 +78,5 @@ interface EventInterface {
fun taggedEmojis(): List<EmojiUrl> fun taggedEmojis(): List<EmojiUrl>
fun matchTag1With(text: String): Boolean fun matchTag1With(text: String): Boolean
fun isExpired(): Boolean fun isExpired(): Boolean
fun hasZapSplitSetup(): Boolean
} }
@@ -49,7 +49,7 @@ class LiveActivitiesChatMessageEvent(
activity: ATag, activity: ATag,
replyTos: List<String>? = null, replyTos: List<String>? = null,
mentions: List<String>? = null, mentions: List<String>? = null,
zapReceiver: String?, zapReceiver: List<ZapSplitSetup>? = null,
keyPair: KeyPair, keyPair: KeyPair,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
markAsSensitive: Boolean, markAsSensitive: Boolean,
@@ -66,8 +66,8 @@ class LiveActivitiesChatMessageEvent(
mentions?.forEach { mentions?.forEach {
tags.add(listOf("p", it)) tags.add(listOf("p", it))
} }
zapReceiver?.let { zapReceiver?.forEach {
tags.add(listOf("zap", it)) tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
} }
if (markAsSensitive) { if (markAsSensitive) {
tags.add(listOf("content-warning", "")) tags.add(listOf("content-warning", ""))
@@ -65,6 +65,7 @@ class LnZapRequestEvent(
pollOption: Int?, pollOption: Int?,
message: String, message: String,
zapType: LnZapEvent.ZapType, zapType: LnZapEvent.ZapType,
toUserPubHex: String?, // Overrides in case of Zap Splits
createdAt: Long = TimeUtils.now() createdAt: Long = TimeUtils.now()
): LnZapRequestEvent { ): LnZapRequestEvent {
var content = message var content = message
@@ -72,7 +73,7 @@ class LnZapRequestEvent(
var pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey() var pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf( var tags = listOf(
listOf("e", originalNote.id()), listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()), listOf("p", toUserPubHex ?: originalNote.pubKey()),
listOf("relays") + relays listOf("relays") + relays
) )
if (originalNote is AddressableEvent) { if (originalNote is AddressableEvent) {
@@ -13,7 +13,7 @@ class NIP24Factory {
subject: String? = null, subject: String? = null,
replyTos: List<String>? = null, replyTos: List<String>? = null,
mentions: List<String>? = null, mentions: List<String>? = null,
zapReceiver: String? = null, zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false, markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
geohash: String? = null geohash: String? = null
@@ -53,7 +53,7 @@ class PollNoteEvent(
valueMinimum: Int?, valueMinimum: Int?,
consensusThreshold: Int?, consensusThreshold: Int?,
closedAt: Int?, closedAt: Int?,
zapReceiver: String?, zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean, markAsSensitive: Boolean,
zapRaiserAmount: Long?, zapRaiserAmount: Long?,
geohash: String? = null geohash: String? = null
@@ -76,8 +76,8 @@ class PollNoteEvent(
tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString())) tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString()))
tags.add(listOf(CLOSED_AT, closedAt.toString())) tags.add(listOf(CLOSED_AT, closedAt.toString()))
if (zapReceiver != null) { zapReceiver?.forEach {
tags.add(listOf("zap", zapReceiver)) tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
} }
if (markAsSensitive) { if (markAsSensitive) {
tags.add(listOf("content-warning", "")) tags.add(listOf("content-warning", ""))
@@ -86,7 +86,7 @@ class PrivateDmEvent(
msg: String, msg: String,
replyTos: List<String>? = null, replyTos: List<String>? = null,
mentions: List<String>? = null, mentions: List<String>? = null,
zapReceiver: String?, zapReceiver: List<ZapSplitSetup>? = null,
keyPair: KeyPair, keyPair: KeyPair,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
publishedRecipientPubKey: ByteArray? = null, publishedRecipientPubKey: ByteArray? = null,
@@ -111,8 +111,8 @@ class PrivateDmEvent(
mentions?.forEach { mentions?.forEach {
tags.add(listOf("p", it)) tags.add(listOf("p", it))
} }
zapReceiver?.let { zapReceiver?.forEach {
tags.add(listOf("zap", it)) tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
} }
if (markAsSensitive) { if (markAsSensitive) {
tags.add(listOf("content-warning", "")) tags.add(listOf("content-warning", ""))
@@ -31,7 +31,7 @@ class TextNoteEvent(
mentions: List<String>?, mentions: List<String>?,
addresses: List<ATag>?, addresses: List<ATag>?,
extraTags: List<String>?, extraTags: List<String>?,
zapReceiver: String?, zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean, markAsSensitive: Boolean,
zapRaiserAmount: Long?, zapRaiserAmount: Long?,
replyingTo: String?, replyingTo: String?,
@@ -79,8 +79,8 @@ class TextNoteEvent(
extraTags?.forEach { extraTags?.forEach {
tags.add(listOf("t", it)) tags.add(listOf("t", it))
} }
zapReceiver?.let { zapReceiver?.forEach {
tags.add(listOf("zap", it)) tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
} }
findURLs(msg).forEach { findURLs(msg).forEach {
tags.add(listOf("r", it)) tags.add(listOf("r", it))