diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index de76b406e..2a1cfc26f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -422,7 +422,7 @@ class Account( return LocalCache.notes[signedEvent.id] } - fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) { + fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null, zapReceiver: String? = null) { if (!isWriteable()) return val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } @@ -435,6 +435,7 @@ class Account( mentions = mentionsHex, addresses = addresses, extraTags = tags, + zapReceiver = zapReceiver, privateKey = loggedIn.privKey!! ) @@ -450,7 +451,8 @@ class Account( valueMaximum: Int?, valueMinimum: Int?, consensusThreshold: Int?, - closedAt: Int? + closedAt: Int?, + zapReceiver: String? = null ) { if (!isWriteable()) return @@ -468,14 +470,15 @@ class Account( valueMaximum = valueMaximum, valueMinimum = valueMinimum, consensusThreshold = consensusThreshold, - closedAt = closedAt + closedAt = closedAt, + zapReceiver = zapReceiver ) // println("Sending new PollNoteEvent: %s".format(signedEvent.toJson())) Client.send(signedEvent) LocalCache.consume(signedEvent) } - fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?) { + fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?, zapReceiver: String? = null) { if (!isWriteable()) return // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } @@ -487,13 +490,14 @@ class Account( channel = toChannel, replyTos = repliesToHex, mentions = mentionsHex, + zapReceiver = zapReceiver, privateKey = loggedIn.privKey!! ) Client.send(signedEvent) LocalCache.consume(signedEvent, null) } - fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List?) { + fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List?, zapReceiver: String? = null) { if (!isWriteable()) return val user = LocalCache.users[toUser] ?: return @@ -506,6 +510,7 @@ class Account( msg = message, replyTos = repliesToHex, mentions = mentionsHex, + zapReceiver = zapReceiver, privateKey = loggedIn.privKey!!, advertiseNip18 = false ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt index e2db58fbd..5fb98d1e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt @@ -20,7 +20,7 @@ class ChannelMessageEvent( companion object { const val kind = 42 - fun create(message: String, channel: String, replyTos: List? = null, mentions: List? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent { + fun create(message: String, channel: String, replyTos: List? = null, mentions: List? = null, zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent { val content = message val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val tags = mutableListOf( @@ -32,6 +32,9 @@ class ChannelMessageEvent( mentions?.forEach { tags.add(listOf("p", it)) } + zapReceiver?.let { + tags.add(listOf("zap", it)) + } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privateKey) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt index c91cd4c7a..0cb71f805 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt @@ -48,7 +48,8 @@ class PollNoteEvent( valueMaximum: Int?, valueMinimum: Int?, consensusThreshold: Int?, - closedAt: Int? + closedAt: Int?, + zapReceiver: String? ): PollNoteEvent { val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val tags = mutableListOf>() @@ -68,6 +69,11 @@ class PollNoteEvent( tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString())) tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString())) tags.add(listOf(CLOSED_AT, closedAt.toString())) + + if (zapReceiver != null) { + tags.add(listOf("zap", zapReceiver)) + } + val id = generateId(pubKey, createdAt, kind, tags, msg) val sig = Utils.sign(id, privateKey) return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt index c820d0ca2..c45c27848 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt @@ -58,6 +58,7 @@ class PrivateDmEvent( msg: String, replyTos: List? = null, mentions: List? = null, + zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000, publishedRecipientPubKey: ByteArray? = null, @@ -79,6 +80,9 @@ class PrivateDmEvent( mentions?.forEach { tags.add(listOf("p", it)) } + zapReceiver?.let { + tags.add(listOf("zap", it)) + } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privateKey) return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt index f64582ba3..b9bc6ab55 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt @@ -26,6 +26,7 @@ class TextNoteEvent( mentions: List?, addresses: List?, extraTags: List?, + zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000 ): TextNoteEvent { @@ -46,9 +47,13 @@ class TextNoteEvent( extraTags?.forEach { tags.add(listOf("t", it)) } + zapReceiver?.let { + tags.add(listOf("zap", it)) + } findURLs(msg).forEach { tags.add(listOf("r", it)) } + val id = generateId(pubKey, createdAt, kind, tags, msg) val sig = Utils.sign(id, privateKey) return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 2350c0907..17d67d7ec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -17,8 +17,12 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CurrencyBitcoin +import androidx.compose.material.icons.outlined.ArrowForwardIos +import androidx.compose.material.icons.outlined.Bolt import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -205,17 +209,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n val url = postViewModel.contentToAddUrl if (url != null) { - ImageVideoDescription( - url, - account.defaultFileServer, - onAdd = { description, server -> - postViewModel.upload(url, description, server, context) - account.changeDefaultFileServer(server) - }, - onCancel = { - postViewModel.contentToAddUrl = null - } - ) + Row(verticalAlignment = Alignment.CenterVertically) { + ImageVideoDescription( + url, + account.defaultFileServer, + onAdd = { description, server -> + postViewModel.upload(url, description, server, context) + account.changeDefaultFileServer(server) + }, + onCancel = { + postViewModel.contentToAddUrl = null + } + ) + } } val user = postViewModel.account?.userProfile() @@ -301,11 +307,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } - Row(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth().height(50.dp), verticalAlignment = Alignment.CenterVertically) { UploadFromGallery( isUploading = postViewModel.isUploadingImage, tint = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(bottom = 10.dp) + modifier = Modifier ) { postViewModel.selectImage(it) } @@ -323,6 +329,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n postViewModel.wantsInvoice = !postViewModel.wantsInvoice } } + + ForwardZapTo(postViewModel) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } } } } @@ -386,6 +396,71 @@ private fun AddLnInvoiceButton( } } +@Composable +private fun ForwardZapTo( + postViewModel: NewPostViewModel, + onClick: () -> Unit +) { + IconButton( + onClick = { + onClick() + } + ) { + Box(Modifier.height(20.dp).width(25.dp)) { + if (!postViewModel.wantsForwardZapTo) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = MaterialTheme.colors.onBackground + ) + Icon( + imageVector = Icons.Default.ArrowForwardIos, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = MaterialTheme.colors.onBackground + ) + } else { + 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 + ) + } + } + } + + if (postViewModel.wantsForwardZapTo) { + OutlinedTextField( + value = postViewModel.forwardZapToEditting, + onValueChange = { + postViewModel.updateZapForwardTo(it) + }, + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).padding(0.dp), + placeholder = { + Text( + text = stringResource(R.string.zap_forward_lnAddress), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + colors = TextFieldDefaults + .outlinedTextFieldColors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } +} + @Composable fun CloseButton(onCancel: () -> Unit) { Button( @@ -555,7 +630,7 @@ fun ImageVideoDescription( fontWeight = FontWeight.W500, modifier = Modifier .padding(start = 10.dp) - .weight(1.0f) + .weight(1.0f).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) IconButton( @@ -579,7 +654,7 @@ fun ImageVideoDescription( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp) + .padding(bottom = 10.dp).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) { if (mediaType.startsWith("image")) { AsyncImage( @@ -588,7 +663,7 @@ fun ImageVideoDescription( contentScale = ContentScale.FillWidth, modifier = Modifier .padding(top = 4.dp) - .fillMaxWidth() + .fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) } else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { var bitmap by remember { mutableStateOf(null) } @@ -625,18 +700,18 @@ fun ImageVideoDescription( onSelect = { selectedServer = fileServers[it].first }, - modifier = Modifier + modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) .weight(1f) ) } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) { OutlinedTextField( label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), value = message, onValueChange = { message = it }, placeholder = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 8494fe4d0..267f8f576 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -37,6 +37,7 @@ open class NewPostViewModel : ViewModel() { var userSuggestions by mutableStateOf>(emptyList()) var userSuggestionAnchor: TextRange? = null + var userSuggestionsMainMessage: Boolean? = null // Images and Videos var contentToAddUrl by mutableStateOf(null) @@ -61,6 +62,11 @@ open class NewPostViewModel : ViewModel() { var canAddInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false) + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + var forwardZapTo by mutableStateOf(null) + var forwardZapToEditting by mutableStateOf(TextFieldValue("")) + open fun load(account: Account, replyingTo: Note?, quote: Note?) { originalNote = replyingTo replyingTo?.let { replyNote -> @@ -90,6 +96,10 @@ open class NewPostViewModel : ViewModel() { canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null contentToAddUrl = null + wantsForwardZapTo = false + forwardZapTo = null + forwardZapToEditting = TextFieldValue("") + this.account = account } @@ -97,14 +107,24 @@ open class NewPostViewModel : ViewModel() { val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text) tagger.run() - if (wantsPoll) { - account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt) - } else if (originalNote?.channel() != null) { - account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions) - } else if (originalNote?.event is PrivateDmEvent) { - account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions) + val zapReceiver = if (wantsForwardZapTo) { + if (forwardZapTo != null) { + forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06 + } else { + forwardZapToEditting.text + } } else { - account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions) + null + } + + if (wantsPoll) { + account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver) + } else if (originalNote?.channel() != null) { + account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver) + } else if (originalNote?.event is PrivateDmEvent) { + account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver) + } else { + account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver) } cancel() @@ -155,6 +175,14 @@ open class NewPostViewModel : ViewModel() { closedAt = null wantsInvoice = false + + wantsForwardZapTo = false + forwardZapTo = null + forwardZapToEditting = TextFieldValue("") + + userSuggestions = emptyList() + userSuggestionAnchor = null + userSuggestionsMainMessage = null } open fun findUrlInMessage(): String? { @@ -176,6 +204,21 @@ open class NewPostViewModel : ViewModel() { if (it.selection.collapsed) { val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") userSuggestionAnchor = it.selection + userSuggestionsMainMessage = true + if (lastWord.startsWith("@") && lastWord.length > 2) { + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + } else { + userSuggestions = emptyList() + } + } + } + + open fun updateZapForwardTo(it: TextFieldValue) { + forwardZapToEditting = it + if (it.selection.collapsed) { + val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + userSuggestionAnchor = it.selection + userSuggestionsMainMessage = false if (lastWord.startsWith("@") && lastWord.length > 2) { userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) } else { @@ -186,15 +229,29 @@ open class NewPostViewModel : ViewModel() { open fun autocompleteWithUser(item: User) { userSuggestionAnchor?.let { - val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") - val lastWordStart = it.end - lastWord.length - val wordToInsert = "@${item.pubkeyNpub()}" + if (userSuggestionsMainMessage == true) { + val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + + message = TextFieldValue( + message.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) + ) + } else { + val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + forwardZapTo = item + + forwardZapToEditting = TextFieldValue( + forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) + ) + } - message = TextFieldValue( - message.text.replaceRange(lastWordStart, it.end, wordToInsert), - TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) - ) userSuggestionAnchor = null + userSuggestionsMainMessage = null userSuggestions = emptyList() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5fb553efd..3db79ad44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -325,5 +325,5 @@ File Server - + LnAddress or @User