From 65468ab4ca7d1dd6e3192f546c11069abd3a5b4d Mon Sep 17 00:00:00 2001 From: SlowBearDigger Date: Sun, 4 Jan 2026 15:08:11 -0500 Subject: [PATCH 1/3] Add SimpleX Chat links and Monero address highlight Implements two community-requested features: 1. SimpleX Chat Integration (Issue #8) - Added 'simplex' field to user metadata (NIP-24 kind 0) - Smart URI handling for simplex:/ and https://simplex.chat/ formats - Profile display with clickable link - User input in profile settings 2. Monero Address Highlight (Issue #9) - Tip button highlights with MoneroOrange when Monero address detected - Uses existing cryptocurrency_addresses field - Seamless integration with existing tipping UI Technical changes: - Backend: Updated UserMetadata, MetadataEvent, Account models - Frontend: Added DisplaySimpleXAddress composable, updated ReactionsRow - Tests: Added GarnetFeaturesTest.kt with serialization tests Closes #8 Closes #9 Part of Garnet Maintenance bounty: https://bounties.monero.social/posts/147 Monero wallet: 42w9YaCW8UwZ2BmQztNmUd6JgYVcjW7LXEMTcQqHdmtFCsSo5RGY2eQg2iZ3WyBSSs63gnhczLkJ46yfr4ojCXWT3H1ZBbR --- .../vitorpamplona/amethyst/model/Account.kt | 2 + .../ui/actions/NewUserMetadataView.kt | 16 +++ .../ui/actions/NewUserMetadataViewModel.kt | 4 + .../amethyst/ui/note/ReactionsRow.kt | 17 ++- .../ui/screen/loggedIn/ProfileScreen.kt | 38 +++++++ .../quartz/events/ContactListEvent.kt | 4 + .../quartz/events/MetadataEvent.kt | 2 + .../quartz/events/GarnetFeaturesTest.kt | 103 ++++++++++++++++++ 8 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 quartz/src/test/java/com/vitorpamplona/quartz/events/GarnetFeaturesTest.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 0a3057932..03eb6da78 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -555,6 +555,7 @@ class Account( nip05: String? = null, lnAddress: String? = null, lnURL: String? = null, + simplex: String? = null, twitter: String? = null, mastodon: String? = null, github: String? = null, @@ -571,6 +572,7 @@ class Account( nip05 = nip05, lnAddress = lnAddress, lnURL = lnURL, + simplex = simplex, moneroAddress = moneroAddress, twitter = twitter, mastodon = mastodon, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt index dd0446d20..755b9fecd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt @@ -259,6 +259,22 @@ fun NewUserMetadataView( Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( + label = { Text(text = "SimpleX") }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.simplex.value, + onValueChange = { postViewModel.simplex.value = it }, + placeholder = { + Text( + text = "simplex:/...", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( label = { Text(text = stringResource(R.string.twitter)) }, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 02dc193f1..ac9a74b19 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -53,6 +53,7 @@ class NewUserMetadataViewModel : ViewModel() { val nip05 = mutableStateOf("") val lnAddress = mutableStateOf("") val lnURL = mutableStateOf("") + val simplex = mutableStateOf("") val twitter = mutableStateOf("") val github = mutableStateOf("") @@ -76,6 +77,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05.value = it.info?.nip05 ?: "" lnAddress.value = it.info?.lud16 ?: "" lnURL.value = it.info?.lud06 ?: "" + simplex.value = it.info?.simplex ?: "" twitter.value = "" github.value = "" @@ -104,6 +106,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05 = nip05.value, lnAddress = lnAddress.value, lnURL = lnURL.value, + simplex = simplex.value, twitter = twitter.value, mastodon = mastodon.value, github = github.value, @@ -122,6 +125,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05.value = "" lnAddress.value = "" lnURL.value = "" + simplex.value = "" twitter.value = "" github.value = "" mastodon.value = "" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index abb8a8b3f..2e02a7c71 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -124,6 +124,7 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Height24dpModifier import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp +import com.vitorpamplona.amethyst.ui.theme.MoneroOrange import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders import com.vitorpamplona.amethyst.ui.theme.ReactionRowExpandButton import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeight @@ -226,7 +227,21 @@ private fun InnerReactionRow( ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) }, six = { - TipReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + val author = baseNote.author + val authorState = author?.live()?.metadata?.observeAsState() + + val hasMoneroAddress = + remember(authorState?.value) { + !author?.info?.moneroAddress().isNullOrBlank() + } + val tipTintColor = + if (hasMoneroAddress) { + MoneroOrange + } else { + MaterialTheme.colorScheme.placeholderText + } + + TipReaction(baseNote, tipTintColor, accountViewModel, nav = nav) }, seven = { ViewCountReaction( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 326a1ba54..3fe7c363b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Message import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -1115,6 +1116,9 @@ private fun DrawAdditionalInfo( val moneroAddress = remember { baseUser.info?.moneroAddress() } DisplayMoneroAddress(address = moneroAddress, userHex = pubkeyHex, accountViewModel = accountViewModel, nav) + val simplex = remember { baseUser.info?.simplex } + DisplaySimpleXAddress(address = simplex, accountViewModel = accountViewModel) + val identities = user.latestMetadata?.identityClaims() if (!identities.isNullOrEmpty()) { identities.forEach { identity: IdentityClaim -> @@ -1327,6 +1331,40 @@ fun DisplayMoneroAddress( } } +@Composable +fun DisplaySimpleXAddress( + address: String?, + accountViewModel: AccountViewModel, +) { + val uri = LocalUriHandler.current + + if (!address.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Message, + contentDescription = "SimpleX", + modifier = Size16Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + + ClickableText( + text = AnnotatedString(address), + onClick = { + val finalUri = if (address.startsWith("simplex:") || address.startsWith("http")) address else "simplex:$address" + runCatching { uri.openUri(finalUri) } + }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + overflow = TextOverflow.Ellipsis, + softWrap = false, + modifier = + Modifier + .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) + .weight(1f), + ) + } + } +} + @Composable @OptIn(ExperimentalLayoutApi::class) private fun DisplayAppRecommendations( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt index c64424bce..83f33567c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt @@ -435,6 +435,8 @@ class UserMetadata { return lud16 ?: lud06 } + var simplex: String? = null + fun moneroAddress(): String? { return cryptoAddresses?.get("monero") } @@ -463,6 +465,7 @@ class UserMetadata { if (banner?.isNotEmpty() == true) banner = banner?.trim() if (website?.isNotEmpty() == true) website = website?.trim() if (domain?.isNotEmpty() == true) domain = domain?.trim() + if (simplex?.isNotEmpty() == true) simplex = simplex?.trim() if (picture?.isBlank() == true) picture = null if (nip05?.isBlank() == true) nip05 = null @@ -475,6 +478,7 @@ class UserMetadata { if (banner?.isBlank() == true) banner = null if (website?.isBlank() == true) website = null if (domain?.isBlank() == true) domain = null + if (simplex?.isBlank() == true) simplex = null } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt index ba2db3399..fe505ef44 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt @@ -187,6 +187,7 @@ class MetadataEvent( lnAddress: String?, lnURL: String?, moneroAddress: String?, + simplex: String?, twitter: String?, mastodon: String?, github: String?, @@ -214,6 +215,7 @@ class MetadataEvent( nip05?.let { addIfNotBlank(currentJson, "nip05", it.trim()) } lnAddress?.let { addIfNotBlank(currentJson, "lud16", it.trim()) } lnURL?.let { addIfNotBlank(currentJson, "lud06", it.trim()) } + simplex?.let { addIfNotBlank(currentJson, "simplex", it.trim()) } moneroAddress?.let { val cryptos = if (currentJson.has("cryptocurrency_addresses")) { diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/events/GarnetFeaturesTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/events/GarnetFeaturesTest.kt new file mode 100644 index 000000000..eaad1a334 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/events/GarnetFeaturesTest.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GarnetFeaturesTest { + private val mapper = jacksonObjectMapper() + + @Test + fun testMoneroAddressRetrieval() { + val metadata = UserMetadata() + metadata.cryptoAddresses = mapOf("monero" to "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXz9ucDeZaYo7kn7sZyj55xXLTxtr5W3e3v6kK8e3S9qV8R8") + + assertEquals("44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXz9ucDeZaYo7kn7sZyj55xXLTxtr5W3e3v6kK8e3S9qV8R8", metadata.moneroAddress()) + } + + @Test + fun testMoneroAddressNull() { + val metadata = UserMetadata() + assertNull(metadata.moneroAddress()) + + metadata.cryptoAddresses = mapOf("bitcoin" to "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertNull(metadata.moneroAddress()) + } + + @Test + fun testSimplexFieldProcessing() { + val metadata = UserMetadata() + metadata.simplex = " simplex:/smp/server/id " + + // Before cleaning, it should have the spaces + assertEquals(" simplex:/smp/server/id ", metadata.simplex) + + metadata.cleanBlankNames() + + // After cleaning, it should be trimmed + assertEquals("simplex:/smp/server/id", metadata.simplex) + } + + @Test + fun testSimplexFieldNullification() { + val metadata = UserMetadata() + metadata.simplex = " " + + metadata.cleanBlankNames() + + // Should be nullified if blank + assertNull(metadata.simplex) + } + + @Test + fun testMetadataSerializationWithSimplex() { + // Mocking a JSON string that represents a MetadataEvent's content + val json = + """ + { + "name": "User", + "simplex": "simplex:/smp/server/id" + } + """.trimIndent() + + val metadata = mapper.readValue(json, UserMetadata::class.java) + assertEquals("simplex:/smp/server/id", metadata.simplex) + } + + @Test + fun testMetadataSerializationWithMonero() { + val json = + """ + { + "name": "User", + "cryptocurrency_addresses": { + "monero": "44AFF" + } + } + """.trimIndent() + + val metadata = mapper.readValue(json, UserMetadata::class.java) + assertEquals("44AFF", metadata.moneroAddress()) + } +} From 42444d11dbaad634c514b74d340585c4e3ba8021 Mon Sep 17 00:00:00 2001 From: SlowBearDigger Date: Sun, 4 Jan 2026 15:29:53 -0500 Subject: [PATCH 2/3] Fix dual-install conflict with Amethyst --- app/build.gradle | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index da6125f91..0cb39d3a6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,7 +4,7 @@ plugins { } android { - namespace 'com.vitorpamplona.amethyst' + namespace 'com.retrnull.garnet' compileSdk 34 defaultConfig { @@ -84,12 +84,12 @@ android { 'zh-rSG', 'zh-rTW' ] - externalNativeBuild { - cmake { - cppFlags "-std=c++11" - arguments "-DANDROID_STL=c++_shared" - } - } + // externalNativeBuild { + // cmake { + // cppFlags "-std=c++11" + // arguments "-DANDROID_STL=c++_shared" + // } + // } } buildTypes { @@ -133,11 +133,11 @@ android { } } - externalNativeBuild { - cmake { - path "CMakeLists.txt" - } - } + // externalNativeBuild { + // cmake { + // path "CMakeLists.txt" + // } + // } splits { abi { From 17b71ae3053988013a412eaa99bee94d598d208a Mon Sep 17 00:00:00 2001 From: SlowBearDigger Date: Sun, 4 Jan 2026 15:39:59 -0500 Subject: [PATCH 3/3] Revert "Fix dual-install conflict with Amethyst" This reverts commit 42444d11dbaad634c514b74d340585c4e3ba8021. --- app/build.gradle | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 0cb39d3a6..da6125f91 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,7 +4,7 @@ plugins { } android { - namespace 'com.retrnull.garnet' + namespace 'com.vitorpamplona.amethyst' compileSdk 34 defaultConfig { @@ -84,12 +84,12 @@ android { 'zh-rSG', 'zh-rTW' ] - // externalNativeBuild { - // cmake { - // cppFlags "-std=c++11" - // arguments "-DANDROID_STL=c++_shared" - // } - // } + externalNativeBuild { + cmake { + cppFlags "-std=c++11" + arguments "-DANDROID_STL=c++_shared" + } + } } buildTypes { @@ -133,11 +133,11 @@ android { } } - // externalNativeBuild { - // cmake { - // path "CMakeLists.txt" - // } - // } + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } + } splits { abi {