Compare commits

...
14 Commits
20 changed files with 904 additions and 467 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 124
versionName "0.35.0"
versionCode 125
versionName "0.35.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -0,0 +1,139 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent.Companion.createEncryptionPrivateKey
import fr.acinq.secp256k1.Hex
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PrivateZapTests {
@Test
fun testPollZap() {
val poll = Event.Companion.fromJson(
"""{
"content": "New poll \n\n #zappoll",
"created_at": 1682440713,
"id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288",
"kind": 6969,
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
"sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279",
"tags": [
[
"poll_option",
"0",
"Test 1"
],
[
"poll_option",
"1",
"Test 2"
],
[
"value_maximum",
"null"
],
[
"value_minimum",
"null"
],
[
"consensus_threshold",
"null"
],
[
"closed_at",
"null"
]
],
"seenOn": [
"wss://relay.damus.io/"
]
}
""",
true
)
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
val privateZapRequest = LnZapRequestEvent.create(
poll,
setOf("wss://relay.damus.io/"),
loggedIn,
0,
"",
LnZapEvent.ZapType.PRIVATE
)
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap =
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)
} else {
fail("Should not be null")
}
}
@Test
fun testKind1PrivateZap() {
val textNote = Event.Companion.fromJson(
"""{
"content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
"created_at": 1682369982,
"id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1",
"kind": 1,
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
"sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1",
"tags": [
[
"p",
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
]
],
"seenOn": [
"wss://relay.damus.io/"
]
}
""",
true
)
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
val privateZapRequest = LnZapRequestEvent.create(
textNote,
setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"),
loggedIn,
null,
"test",
LnZapEvent.ZapType.PRIVATE
)
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap =
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)
} else {
fail("Should not be null")
}
}
}
@@ -44,7 +44,7 @@ class Account(
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
@@ -691,11 +691,57 @@ class Account(
}
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
decryptZapContentAuthor(note)?.content()
} else {
event?.content()
}
}
fun decryptZapContentAuthor(note: Note): Event? {
val event = note.event
val loggedInPrivateKey = loggedIn.privKey
return if (event is LnZapRequestEvent && loggedInPrivateKey != null && event.isPrivateZap()) {
val recipientPK = event.zappedAuthor().firstOrNull()
val recipientPost = event.zappedPost().firstOrNull()
if (recipientPK == userProfile().pubkeyHex) {
// if the receiver is logged in, these are the params.
val privateKeyToUse = loggedInPrivateKey
val pubkeyToUse = event.pubKey
LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse)
} else {
// if the sender is logged in, these are the params
val altPubkeyToUse = recipientPK
val altPrivateKeyToUse = if (recipientPost != null) {
LnZapRequestEvent.createEncryptionPrivateKey(
loggedInPrivateKey.toHexKey(),
recipientPost,
event.createdAt
)
} else if (recipientPK != null) {
LnZapRequestEvent.createEncryptionPrivateKey(
loggedInPrivateKey.toHexKey(),
recipientPK,
event.createdAt
)
} else {
null
}
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
} else {
null
}
}
} else {
null
}
}
fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
liveLanguages.invalidateData()
@@ -200,9 +200,11 @@ open class Note(val idHex: String) {
}
}
fun isZappedBy(user: User): Boolean {
fun isZappedBy(user: User, account: Account): Boolean {
// Zaps who the requester was the user
return zaps.any { it.key.author === user }
return zaps.any {
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
}
}
fun isReactedBy(user: User): Boolean {
@@ -14,7 +14,7 @@ object Nip47 {
val url = Uri.parse(uri)
if (url.scheme != "nostrwalletconnect" || url.scheme != "nostr+walletconnect") {
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
throw IllegalArgumentException("Not a Wallet Connect QR Code")
}
@@ -19,7 +19,7 @@ open class Event(
@SerializedName("created_at") val createdAt: Long,
val kind: Int,
val tags: List<List<String>>,
var content: String,
val content: String,
val sig: HexKey
) : EventInterface {
override fun id(): HexKey = id
@@ -24,6 +24,8 @@ class LnZapRequestEvent(
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
companion object {
const val kind = 9734
@@ -135,26 +137,27 @@ class LnZapRequestEvent(
if (parts.size != 2) {
throw IllegalArgumentException("Invalid message format")
}
val iv = parts[1].run { Bech32.decode(this) }
val encryptedMsg = parts.first().run { Bech32.decode(this) }
val iv = parts[1].run { Bech32.decode(this).second }
val encryptedMsg = parts.first().run { Bech32.decode(this).second }
val encryptedBytes = Bech32.five2eight(encryptedMsg, 0)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv.second, 0)))
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0)))
try {
val decryptedMsgBytes = cipher.doFinal(Bech32.five2eight(encryptedMsg.second, 0))
val decryptedMsgBytes = cipher.doFinal(encryptedBytes)
return String(decryptedMsgBytes)
} catch (ex: BadPaddingException) {
throw IllegalArgumentException("Bad padding")
throw IllegalArgumentException("Bad padding: ${ex.message}")
}
}
fun checkForPrivateZap(zaprequest: Event, loggedInUserPrivKey: ByteArray): Event? {
val anonTag = zaprequest.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" }
if (anonTag != null && anonTag.size > 1) {
val encnote = anonTag?.elementAt(1)
if (encnote != null && encnote != "") {
fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) {
val encnote = anonTag[1]
if (encnote.isNotBlank()) {
try {
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray())
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) {
return decryptedEvent
@@ -82,7 +82,8 @@ open class NewPostViewModel : ViewModel() {
}
quote?.let {
message = TextFieldValue(message.text + "\n\n@${it.toNEvent()}")
message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
}
canAddInvoice = account.userProfile().info?.lnAddress() != null
@@ -5,8 +5,11 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
@@ -29,7 +32,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@Composable
fun TextSpinner(label: String, placeholder: String, options: List<String>, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
fun TextSpinner(label: String, placeholder: String, options: List<String>, explainers: List<String>? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
@@ -62,7 +65,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
currentText = options[it]
optionsShowing = false
onSelect(it)
@@ -72,7 +75,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
}
@Composable
fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
@@ -81,7 +84,6 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
LazyColumn() {
itemsIndexed(options) { index, item ->
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 16.dp)
@@ -89,7 +91,25 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
onSelect(index)
}
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
Column() {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
}
explainers?.getOrNull(index)?.let {
Spacer(modifier = Modifier.height(5.dp))
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = it, color = Color.Gray)
}
}
}
}
if (index < options.lastIndex) {
Divider(color = Color.LightGray, thickness = 0.25.dp)
@@ -248,6 +248,13 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
Column() {
val pagerState: PagerState = remember { PagerState() }
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
val page = allImages.indexOf(imageUrl)
if (page > -1) {
pagerState.scrollToPage(page)
}
}
Row(
modifier = Modifier
.padding(10.dp)
@@ -6,6 +6,7 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -36,10 +37,7 @@ 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.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -86,9 +84,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
.combinedClickable(
onClick = {
if (noteEvent is ChannelMessageEvent) {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
note
.channel()
?.let {
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
@@ -140,16 +140,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
for (i in multiSetCard.zapEvents) {
var decryptedContent = (i.value.event as LnZapEvent).zapRequest?.let {
LnZapRequestEvent.checkForPrivateZap(it, NostrAccountDataSource.account.loggedIn.privKey!!)
}
if (decryptedContent != null) {
(i.key.event as Event).content = decryptedContent.content
i.key.author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
}
}
AuthorGallery(multiSetCard.zapEvents.keys, navController, account, accountViewModel, "zap")
AuthorGalleryZaps(multiSetCard.zapEvents, navController, account, accountViewModel)
}
}
@@ -222,12 +213,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
@Composable
fun AuthorGallery(
authorNotes: Collection<Note>,
fun AuthorGalleryZaps(
authorNotes: Map<Note, Note>,
navController: NavController,
account: Account,
accountViewModel: AccountViewModel,
kind: String = "nonzap"
accountViewModel: AccountViewModel
) {
val accountState by account.userProfile().live().follows.observeAsState()
val accountUser = accountState?.user ?: return
@@ -235,38 +225,94 @@ fun AuthorGallery(
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
if (it.event?.content() != "" && kind == "zap") {
Row(Modifier.fillMaxWidth()) {
FastNoteAuthorPicture(
note = it,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
}
} else {
Row() {
FastNoteAuthorPicture(
note = it,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
}
AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel)
}
}
}
}
@Composable
private fun AuthorPictureAndComment(
zapRequest: Note,
zapEvent: Note,
navController: NavController,
accountUser: User,
accountViewModel: AccountViewModel
) {
val author = zapRequest.author ?: return
var content by remember { mutableStateOf<Pair<User, String?>>(Pair(author, null)) }
LaunchedEffect(key1 = zapRequest.idHex) {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content = Pair(author, decryptedContent.content)
} else {
if (!zapRequest.event?.content().isNullOrBlank()) {
content = Pair(author, zapRequest.event?.content())
}
if (it.event?.content() != "" && kind == "zap") {
Row(Modifier.fillMaxWidth()) {
it.event?.let {
TranslatableRichTextViewer(
content = it.content(),
canPreview = true,
tags = null,
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
}
}
AuthorPictureAndComment(content.first, content.second, navController, accountUser, accountViewModel)
}
@Composable
private fun AuthorPictureAndComment(
author: User,
comment: String?,
navController: NavController,
accountUser: User,
accountViewModel: AccountViewModel
) {
val modifier = if (!comment.isNullOrBlank()) {
Modifier.fillMaxWidth()
} else {
Modifier
}
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
FastNoteAuthorPicture(
author = author,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
if (!comment.isNullOrBlank()) {
Spacer(modifier = Modifier.width(5.dp))
TranslatableRichTextViewer(
content = comment,
canPreview = true,
tags = null,
modifier = Modifier.weight(1f),
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
@Composable
fun AuthorGallery(
authorNotes: Collection<Note>,
navController: NavController,
account: Account,
accountViewModel: AccountViewModel
) {
val accountState by account.userProfile().live().follows.observeAsState()
val accountUser = accountState?.user ?: return
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
val author = it.author
if (author != null) {
AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel)
}
}
}
@@ -275,15 +321,12 @@ fun AuthorGallery(
@Composable
fun FastNoteAuthorPicture(
note: Note,
author: User,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
// can't be null if here
val author = note.author ?: return
val userState by author.live().metadata.observeAsState()
val user = userState?.user ?: return
@@ -781,25 +781,29 @@ fun BadgeDisplay(baseNote: Note) {
@Composable
fun FileHeaderDisplay(note: Note) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
val blurHash = event.blurhash()
val hash = event.hash()
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
if (isImage || isVideo) {
val content = if (isImage) {
ZoomableImage(fullUrl, description, hash, blurHash)
} else {
ZoomableVideo(fullUrl, description, hash)
var content by remember { mutableStateOf<ZoomableContent?>(null) }
LaunchedEffect(key1 = event.id) {
withContext(Dispatchers.IO) {
val blurHash = event.blurhash()
val hash = event.hash()
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
content = if (isImage) {
ZoomableImage(fullUrl, description, hash, blurHash)
} else {
ZoomableVideo(fullUrl, description, hash)
}
}
ZoomableContentView(content = content, listOf(content))
} else {
UrlPreview(fullUrl, "$fullUrl ")
}
content?.let {
ZoomableContentView(content = it, listOf(it))
} ?: UrlPreview(fullUrl, "$fullUrl ")
}
@Composable
@@ -31,12 +31,13 @@ import androidx.compose.ui.window.Popup
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.*
import kotlin.math.roundToInt
@@ -51,105 +52,150 @@ fun PollNote(
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val pollViewModel = PollNoteViewModel()
pollViewModel.account = account
pollViewModel.load(zappedNote)
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
val optionTally = pollViewModel.optionVoteTally(poll_op.key)
val color = if (
pollViewModel.consensusThreshold != null &&
optionTally >= pollViewModel.consensusThreshold!!
) {
Color.Green.copy(alpha = 0.32f)
} else {
MaterialTheme.colors.primary.copy(alpha = 0.32f)
}
OptionNote(
poll_op.key,
poll_op.value,
pollViewModel,
baseNote,
accountViewModel,
canPreview,
backgroundColor,
navController
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp)
) {
if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) {
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
poll_op.key,
nonClickablePrepend = {
Box(
Modifier.fillMaxWidth(0.75f).clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
color,
RoundedCornerShape(15.dp)
)
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
progress = optionTally.toFloat()
@Composable
private fun OptionNote(
optionNumber: Int,
optionText: String,
pollViewModel: PollNoteViewModel,
baseNote: Note,
accountViewModel: AccountViewModel,
canPreview: Boolean,
backgroundColor: Color,
navController: NavController
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp)
) {
if (!pollViewModel.canZap()) {
val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) }
LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) {
val myTally = pollViewModel.optionVoteTally(optionNumber)
val color = if (
pollViewModel.consensusThreshold != null &&
myTally >= pollViewModel.consensusThreshold!!
) {
Color.Green.copy(alpha = 0.32f)
} else {
defaultColor
}
if (myTally > optionTally.first || color != optionTally.second) {
optionTally = Pair(myTally, color)
}
}
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
optionNumber,
nonClickablePrepend = {
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
optionTally.second,
RoundedCornerShape(15.dp)
)
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = optionTally.second,
progress = optionTally.first.toFloat()
)
Row(
verticalAlignment = Alignment.CenterVertically
Row(
verticalAlignment = Alignment.CenterVertically
) {
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier
.padding(horizontal = 10.dp)
.width(40.dp)
) {
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.padding(horizontal = 10.dp).width(40.dp)
) {
Text(
text = "${(optionTally.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold
)
}
Text(
text = "${(optionTally.first.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold
)
}
Column(modifier = Modifier.fillMaxWidth().padding(15.dp)) {
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier,
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp)
) {
TranslatableRichTextViewer(
optionText,
canPreview,
Modifier,
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
)
}
}
},
clickablePrepend = {
}
)
} else {
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
poll_op.key,
nonClickablePrepend = {},
clickablePrepend = {
Box(
Modifier.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
MaterialTheme.colors.primary,
RoundedCornerShape(15.dp)
)
) {
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
},
clickablePrepend = {
}
)
} else {
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
optionNumber,
nonClickablePrepend = {},
clickablePrepend = {
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
MaterialTheme.colors.primary,
RoundedCornerShape(15.dp)
)
}
) {
TranslatableRichTextViewer(
optionText,
canPreview,
Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
)
}
)
}
}
)
}
}
}
@@ -288,7 +334,17 @@ fun ZapVote(
clickablePrepend()
if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!optionWasZappedByLoggedInUser) {
optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())
}
}
}
if (optionWasZappedByLoggedInUser) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
@@ -315,8 +371,18 @@ fun ZapVote(
}
}
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true
}
}
}
// only show tallies after a user has zapped note
if (baseNote.author == accountViewModel.userProfile() || zappedNote?.isZappedBy(accountViewModel.userProfile()) == true) {
if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) {
Text(
showAmount(pollViewModel.zappedPollOptionAmount(pollOption)),
fontSize = 14.sp,
@@ -378,7 +444,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
LnZapEvent.ZapType.PUBLIC
account.defaultZapType
)
onDismiss()
}
@@ -404,7 +470,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
LnZapEvent.ZapType.PUBLIC
account.defaultZapType
)
onDismiss()
}
@@ -496,8 +562,7 @@ fun ZapVoteAmountChoicePopup(
"",
context,
onError,
onProgress,
LnZapEvent.ZapType.PUBLIC
onProgress
)
onDismiss()
}
@@ -522,8 +587,7 @@ fun ZapVoteAmountChoicePopup(
"",
context,
onError,
onProgress,
LnZapEvent.ZapType.PUBLIC
onProgress
)
onDismiss()
}
@@ -33,6 +33,13 @@ class PollNoteViewModel {
totalZapped = totalZapped()
}
fun canZap(): Boolean {
val account = account ?: return false
val user = account.userProfile() ?: return false
val note = pollNote ?: return false
return user != note.author && !note.isZappedBy(user, account)
}
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
fun isPollClosed(): Boolean = closedAt?.let { // allow 2 minute leeway for zap to propagate
@@ -87,14 +94,12 @@ class PollNoteViewModel {
}
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
if (pollNote?.zaps?.any { it.key.author === user } == true) {
pollNote!!.zaps
.any {
val event = it.value?.event as? LnZapEvent
event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex
}
}
return false
return pollNote!!.zaps
.any {
val zapEvent = it.value?.event as? LnZapEvent
val privateZapAuthor = account?.decryptZapContentAuthor(it.key)
zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex)
}
}
fun zappedPollOptionAmount(option: Int): BigDecimal {
@@ -314,6 +314,16 @@ fun ZapReaction(
var zappingProgress by remember { mutableStateOf(0f) }
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true
}
}
}
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
@@ -412,7 +422,7 @@ fun ZapReaction(
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
}
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
if (wasZappedByLoggedInUser) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -69,9 +70,11 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -85,6 +88,7 @@ class UpdateZapAmountViewModel : ViewModel() {
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE)
fun load(account: Account) {
this.account = account
@@ -92,6 +96,7 @@ class UpdateZapAmountViewModel : ViewModel() {
this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectSecret = account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.selectedZapType = account.defaultZapType
}
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
@@ -113,6 +118,7 @@ class UpdateZapAmountViewModel : ViewModel() {
fun sendPost() {
account?.changeZapAmounts(amountSet)
account?.changeDefaultZapType(selectedZapType)
if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) {
val pubkeyHex = try {
@@ -160,7 +166,8 @@ class UpdateZapAmountViewModel : ViewModel() {
fun hasChanged(): Boolean {
return (
amountSet != account?.zapAmountChoices ||
selectedZapType != account?.defaultZapType ||
amountSet != account?.zapAmountChoices ||
walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") ||
walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") ||
walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "")
@@ -188,6 +195,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
val postViewModel: UpdateZapAmountViewModel = viewModel()
val uri = LocalUriHandler.current
val zapTypes = listOf(
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)),
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
LaunchedEffect(account) {
postViewModel.load(account)
if (nip47uri != null) {
@@ -205,12 +222,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
decorFitsSystemWindows = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Surface(
modifier = Modifier
.fillMaxWidth()
) {
Column(modifier = Modifier.padding(10.dp).imePadding()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -232,261 +253,293 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
Spacer(modifier = Modifier.height(10.dp))
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
Row(
modifier = Modifier
.fillMaxWidth()
) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.animateContentSize()) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
postViewModel.amountSet.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
onClick = {
postViewModel.removeAmount(amountInSats)
}
) {
Text(
"${
showAmount(
amountInSats.toBigDecimal().setScale(1)
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.animateContentSize()) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
postViewModel.amountSet.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
onClick = {
postViewModel.removeAmount(amountInSats)
}
) {
Text(
"${
showAmount(
amountInSats.toBigDecimal().setScale(1)
)
} ",
color = Color.White,
textAlign = TextAlign.Center
)
} ",
color = Color.White,
textAlign = TextAlign.Center
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
value = postViewModel.nextAmount,
onValueChange = {
postViewModel.nextAmount = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.weight(1f)
)
Button(
onClick = { postViewModel.addAmount() },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(R.string.add), color = Color.White)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextSpinner(
label = stringResource(id = R.string.zap_type_explainer),
placeholder = zapTypes.filter { it.first == account.defaultZapType }
.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
postViewModel.selectedZapType = zapTypes[it].first
},
modifier = Modifier
.weight(1f)
.padding(end = 5.dp)
)
}
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
var qrScanning by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service),
Modifier.weight(1f)
)
IconButton(onClick = {
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
}) {
Icon(
painter = painterResource(R.drawable.alby),
null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified
)
}
IconButton(onClick = {
qrScanning = true
}) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service_explainer),
Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 14.sp
)
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
if (!it.isNullOrEmpty()) {
try {
postViewModel.updateNIP47(it)
} catch (e: IllegalArgumentException) {
scope.launch {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
.show()
}
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) },
value = postViewModel.walletConnectPubkey,
onValueChange = {
postViewModel.walletConnectPubkey = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None
),
placeholder = {
Text(
text = "npub, hex",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectRelay,
onValueChange = { postViewModel.walletConnectRelay = it },
placeholder = {
Text(
text = "wss://relay.server.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
},
singleLine = true
)
}
var showPassword by remember {
mutableStateOf(false)
}
val scope = rememberCoroutineScope()
val context = LocalContext.current
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
showPassword = true
}
}
val authTitle =
stringResource(id = R.string.wallet_connect_service_show_secret)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectSecret,
onValueChange = { postViewModel.walletConnectSecret = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = stringResource(R.string.wallet_connect_service_secret_placeholder),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
IconButton(onClick = {
if (!showPassword) {
authenticate(
authTitle,
context,
scope,
keyguardLauncher
) {
showPassword = true
}
} else {
showPassword = false
}
}) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) {
stringResource(R.string.show_password)
} else {
stringResource(
R.string.hide_password
)
}
)
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
value = postViewModel.nextAmount,
onValueChange = {
postViewModel.nextAmount = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.weight(1f)
)
Button(
onClick = { postViewModel.addAmount() },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(R.string.add), color = Color.White)
}
}
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
var qrScanning by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service),
Modifier.weight(1f)
)
IconButton(onClick = {
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
}) {
Icon(
painter = painterResource(R.drawable.alby),
null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
)
}
IconButton(onClick = {
qrScanning = true
}) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service_explainer),
Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 14.sp
)
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
if (!it.isNullOrEmpty()) {
try {
postViewModel.updateNIP47(it)
} catch (e: IllegalArgumentException) {
scope.launch {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
.show()
}
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) },
value = postViewModel.walletConnectPubkey,
onValueChange = {
postViewModel.walletConnectPubkey = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None
),
placeholder = {
Text(
text = "npub, hex",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectRelay,
onValueChange = { postViewModel.walletConnectRelay = it },
placeholder = {
Text(
text = "wss://relay.server.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
},
singleLine = true
)
}
var showPassword by remember {
mutableStateOf(false)
}
val scope = rememberCoroutineScope()
val context = LocalContext.current
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
showPassword = true
}
}
val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectSecret,
onValueChange = { postViewModel.walletConnectSecret = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = stringResource(R.string.wallet_connect_service_secret_placeholder),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
IconButton(onClick = {
if (!showPassword) {
authenticate(authTitle, context, scope, keyguardLauncher) {
showPassword = true
}
} else {
showPassword = false
}
}) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) {
stringResource(R.string.show_password)
} else {
stringResource(
R.string.hide_password
)
}
)
}
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
)
}
}
}
@@ -60,6 +60,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: ZapOptionstViewModel = viewModel()
LaunchedEffect(account) {
postViewModel.load(account)
}
@@ -67,13 +68,14 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
var zappingProgress by remember { mutableStateOf(0f) }
val zapTypes = listOf(
Pair(LnZapEvent.ZapType.PUBLIC, "Public"),
Pair(LnZapEvent.ZapType.PRIVATE, "Private"),
Pair(LnZapEvent.ZapType.ANONYMOUS, "Anonymous"),
Pair(LnZapEvent.ZapType.NONZAP, "Non-Zap")
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)),
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
var selectedZapType by remember { mutableStateOf(account.defaultZapType) }
Dialog(
@@ -150,11 +152,23 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.padding(end = 5.dp)
.weight(1f)
)
TextSpinner(
label = stringResource(id = R.string.zap_type),
placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
selectedZapType = zapTypes[it].first
},
modifier = Modifier
.weight(1f)
.padding(end = 5.dp)
)
}
Spacer(modifier = Modifier.height(5.dp))
Row(
modifier = Modifier
@@ -189,20 +203,10 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.padding(end = 5.dp)
.weight(1f)
)
}
TextSpinner(
label = "Zap Type",
placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second,
options = zapOptions,
onSelect = {
selectedZapType = zapTypes[it].first
account.changeDefaultZapType(selectedZapType)
},
modifier = Modifier.fillMaxWidth()
)
}
}
}
@@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
import com.vitorpamplona.amethyst.service.model.ReportEvent
@@ -54,6 +55,10 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.delete(account.boostsTo(note))
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType)
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
@@ -164,6 +169,10 @@ class AccountViewModel(private val account: Account) : ViewModel() {
return account.decryptContent(note)
}
fun decryptZap(note: Note): Event? {
return account.decryptZapContentAuthor(note)
}
fun hide(user: User) {
account.hideUser(user.pubkeyHex)
}
+12
View File
@@ -289,4 +289,16 @@
<string name="lightning_create_and_add_invoice">Létrehoz és Hozzáad</string>
<string name="poll_author_no_vote">A szavazás létrehozója sajátjára nem szavazhat.</string>
<string name="poll_hashtag" translatable="false">#zappoll</string>
<string name="hash_verification_passed">A kép a bejegyzés óta ugyanaz</string>
<string name="hash_verification_failed">A kép megváltozott. Lehet, hogy a szerző nem látta a változást</string>
<string name="content_description_add_image">Kép Hozzáadása</string>
<string name="content_description_add_video">Videó Hozzáadása</string>
<string name="content_description_add_document">Dokumentum Hozzáadása</string>
<string name="add_content">Létrehoz és Hozzáad</string>
<string name="content_description">A tartalom leírása</string>
<string name="content_description_example">Naplementekor egy kék csónak egy fehér homokos tengerparton</string>
</resources>
+15
View File
@@ -307,4 +307,19 @@
<string name="content_description">Description of the contents</string>
<string name="content_description_example">A blue boat in a white sandy beach at sunset</string>
<string name="zap_type">Zap Type</string>
<string name="zap_type_explainer">Zap Type for all options</string>
<string name="zap_type_public">Public</string>
<string name="zap_type_public_explainer">Everybody can see the transaction and message</string>
<string name="zap_type_private">Private</string>
<string name="zap_type_private_explainer">Sender and Receiver can see each other and read the message</string>
<string name="zap_type_anonymous">Anonymous</string>
<string name="zap_type_anonymous_explainer">Receiver and the public does not know who sent the payment</string>
<string name="zap_type_nonzap">Non-Zap</string>
<string name="zap_type_nonzap_explainer">No trace in Nostr, only in Lightning</string>
</resources>