From 09c4d70048b78443b723f5cc78158011f1e0ce3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 22:45:06 +0000 Subject: [PATCH 01/21] fix(blossom): only bridge to local cache when URL is BUD-01 layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bridgeUrl` accepted the imeta `x` hash as a sufficient signal to route a media URL through the local Blossom cache, even when the URL itself didn't have the sha256 in its last path segment. For URLs like https://i.nostr.build/M5AwJ.gif the upstream serves the blob under an opaque filename, so the resulting `xs=https://i.nostr.build` hint sent the local cache after https://i.nostr.build/.gif on miss — which 404s because the real blob isn't at that path. Match the behaviour `bridgeProfilePictureUrl` already had: require `extractSha256FromUrlPath(url)` to succeed before bridging. The imeta hash is still preferred for canonical casing when present, but is no longer enough on its own. --- .../commons/richtext/MediaUrlContentExt.kt | 17 +++++++---- .../richtext/MediaUrlContentExtTest.kt | 29 +++++++++++++++---- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt index c00ed10519..29d750d34e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt @@ -104,13 +104,20 @@ private fun bridgeUrl( if (url.startsWith("blossom:", ignoreCase = true)) return url if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url - val sha = - explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) } - ?: extractSha256FromUrlPath(url) - ?: return url + // The local Blossom cache fetches `/.` on miss per BUD-01, + // which only works when the upstream URL is itself BUD-01 layout. For + // non-BUD-01 URLs (e.g. https://i.nostr.build/M5AwJ.gif) the imeta `x` + // hash identifies the blob but the upstream server doesn't host it at + // /., so trusting only `explicitHash` would point the cache + // at a 404. Require the sha to be in the URL path before bridging. + val urlSha = extractSha256FromUrlPath(url) ?: return url + + // Prefer the imeta hash when it's a valid sha256 (authoritative casing), + // otherwise fall back to what was parsed from the URL. + val sha = explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) } ?: urlSha val ext = guessExtension(url, mimeType) - val serverBase = extractServerBase(url, sha) ?: return url + val serverBase = extractServerBase(url, urlSha) ?: return url val authors = authorPubKey diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt index 19301c0dc5..84dcf80c83 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt @@ -80,14 +80,15 @@ class MediaUrlContentExtTest { @Test fun bridgeOnInfersExtensionFromMimeType() { - val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha, mimeType = "image/png") + // BUD-01 allows `` without an extension; mimeType supplies one. + val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha, mimeType = "image/png") val result = image.toCoilModel(useLocalBlossomBridge = true) assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result") } @Test fun bridgeOnFallsBackToBinExtension() { - val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha) + val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha) val result = image.toCoilModel(useLocalBlossomBridge = true) assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result") } @@ -100,7 +101,7 @@ class MediaUrlContentExtTest { @Test fun uppercaseHashNormalisedToLowercase() { - val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = sha.uppercase()) + val image = MediaUrlImage(url = "https://cdn.example.com/${sha.uppercase()}.jpg", hash = sha.uppercase()) val result = image.toCoilModel(useLocalBlossomBridge = true) assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result") } @@ -110,7 +111,7 @@ class MediaUrlContentExtTest { val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5" val image = MediaUrlImage( - url = "https://cdn.example.com/foo.jpg", + url = "https://cdn.example.com/$sha.jpg", hash = sha, authorPubKey = authorPub, ) @@ -122,7 +123,7 @@ class MediaUrlContentExtTest { fun invalidAuthorPubKeyDropped() { val image = MediaUrlImage( - url = "https://cdn.example.com/foo.jpg", + url = "https://cdn.example.com/$sha.jpg", hash = sha, authorPubKey = "not-a-pubkey", ) @@ -130,6 +131,24 @@ class MediaUrlContentExtTest { assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result) } + @Test + fun bridgeOnSkipsNonBud01UrlEvenWithImetaHash() { + // The imeta `x` hash refers to a blob whose canonical Blossom location + // is /., but the upstream URL serves it under a different + // path (https://i.nostr.build/M5AwJ.gif). Routing this through the + // local cache would set xs=https://i.nostr.build, and the cache would + // fetch https://i.nostr.build/.gif on miss, which 404s. + val url = "https://i.nostr.build/M5AwJ.gif" + val image = MediaUrlImage(url = url, hash = sha) + assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true)) + } + + @Test + fun bridgeProfilePictureUrlSkipsNonBud01UrlEvenWithImetaHash() { + val url = "https://i.nostr.build/M5AwJ.gif" + assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) + } + @Test fun bridgeProfilePictureUrlNullReturnsNull() { assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true)) From 968a396779ee779da585b68ca643de4c1a2ad065 Mon Sep 17 00:00:00 2001 From: m Date: Sun, 17 May 2026 17:09:42 +1000 Subject: [PATCH 02/21] feat(electrumx): add electrum.nmc.ethicnology.com to default server set Adds a third public Namecoin ElectrumX server to the default and Tor-preferred lists in DEFAULT_ELECTRUMX_SERVERS / TOR_ELECTRUMX_SERVERS: electrum.nmc.ethicnology.com:50002 (IPv4 142.44.246.181, OVH Canada) Operated by @ethicnology, who ships the namecoind + ElectrumX + mempool podman stack at github.com/ethicnology/namecoin-compose. Probed live: - server.version -> ElectrumX 1.19.0, protocol 1.4 - server.features -> Namecoin mainnet genesis 000000000062b72c...c770 - scripthash.get_history for d/testls -> full history (heights up to 822885), and blockchain.transaction.get decodes the OP_NAME_UPDATE output correctly. Same code path used by ElectrumXClient against all other public servers, no client changes required. TLS uses a publicly-trusted Let's Encrypt cert, so usePinnedTrustStore is left at the default (false). This makes it the first entry in the list whose TLS does NOT depend on PINNED_ELECTRUMX_CERTS, and adds useful diversity: - electrumx.testls.space (self-signed, pinned, often ECONNRESETs) - nmc2.bitcoins.sk / 46.229.238.187 (self-signed, pinned) - relay.testls.bit / 23.158.233.10 (self-signed, pinned) - electrum.nmc.ethicnology.com (LE cert, system trust store) If every self-signed peer is unreachable (e.g. corporate networks that strip unknown CAs but allow LE chains), resolution can still succeed. No bare-IP companion entry is added for 142.44.246.181: unlike the 46.229.238.187 / 23.158.233.10 pinned peers (which use DER-SHA256 pinning that ignores hostname verification), an IP-literal endpoint against the LE cert would fail standard hostname verification under the system trust manager (SAN covers only the hostname). The IP is captured in this commit message and the source comment for reference. Verification on this branch: - :quartz:spotlessCheck OK - :quartz:jvmTest OK (BitRelayResolverTest etc. unchanged) --- .../namecoin/ElectrumXServer.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt index d98c77ac76..992b55328d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt @@ -110,6 +110,26 @@ val DEFAULT_ELECTRUMX_SERVERS = // nmc2.bitcoins.sk) so resolvers that have an unhealthy DNS path can // still reach the server. Cert pin works by SHA-256 of DER, no SNI required. ElectrumxServer("23.158.233.10", 50002, useSsl = true, usePinnedTrustStore = true), + // electrum.nmc.ethicnology.com — third public Namecoin ElectrumX deployment, + // operated by @ethicnology (github.com/ethicnology/namecoin-compose, a + // namecoind + ElectrumX + mempool podman stack). ElectrumX 1.19.0, + // Namecoin mainnet genesis (000000000062b72c…c770). + // + // Uses a publicly-trusted Let's Encrypt certificate, so usePinnedTrustStore + // is left at the default (false) — the system trust store is sufficient. + // This makes it the first entry in the list whose TLS does NOT rely on + // PINNED_ELECTRUMX_CERTS, and provides graceful fallback if every + // self-signed peer above is unreachable (e.g. corporate networks that + // strip unknown CAs but allow LE). + ElectrumxServer("electrum.nmc.ethicnology.com", 50002, useSsl = true, usePinnedTrustStore = false), + // Note: no bare-IP companion entry for electrum.nmc.ethicnology.com. + // Unlike the 46.229.238.187 / 23.158.233.10 peers above (which use + // usePinnedTrustStore=true and DER-SHA256 pinning that doesn't care + // about hostname verification), this server's TLS chains to a publicly- + // trusted Let's Encrypt cert whose SAN covers only electrum.nmc.ethicnology.com. + // Connecting by the bare IP 142.44.246.181 would fail standard hostname + // verification under the system trust manager, so the entry would never + // succeed in practice. The hostname entry above is the only useful form. ) /** Tor-preferred server list: onion primary, clearnet fallback. */ @@ -135,4 +155,6 @@ val TOR_ELECTRUMX_SERVERS = ElectrumxServer("relay.testls.bit", 50002, useSsl = true, usePinnedTrustStore = true), // Bare IP peer (same operator/cert/box). See clearnet list above. ElectrumxServer("23.158.233.10", 50002, useSsl = true, usePinnedTrustStore = true), + // electrum.nmc.ethicnology.com — public LE-cert ElectrumX. See clearnet list above. + ElectrumxServer("electrum.nmc.ethicnology.com", 50002, useSsl = true, usePinnedTrustStore = false), ) From 9390c25b2c54cd988cd9f43cb342fe81ebecbfcb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 17:49:45 +0000 Subject: [PATCH 03/21] feat: extract notification settings into dedicated screen Group every notification-related preference under a new "Notifications" entry in the settings menu, rendered with the modern card/section design used by SecurityFiltersScreen. Moves out of "UI preferences": - Push notification provider (UnifiedPush, fdroid) - Always-on notification service + battery optimization banner - Split notifications by Follows The fdroid PushNotificationSettingsRow becomes PushNotificationProviderTile built around SettingsBlockTile; the play stub gains a matching HasPushNotificationProvider() so the push section is hidden on Play builds. --- .../components/SelectNotificationProvider.kt | 41 ++-- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/settings/AllSettingsScreen.kt | 6 + .../loggedIn/settings/AppSettingsScreen.kt | 106 +--------- .../settings/NotificationSettingsScreen.kt | 192 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 8 +- .../components/SelectNotificationProvider.kt | 5 +- 8 files changed, 240 insertions(+), 122 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index d0c5b3397d..ee8b2139af 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList @@ -208,23 +208,34 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList, Immuta } @Composable -fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) { +fun HasPushNotificationProvider(): Boolean = true + +@Composable +fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) { val context = LocalContext.current LoadDistributors { currentDistributor, list, readableListWithExplainer -> - SettingsRow( - R.string.push_server_title, - R.string.push_server_explainer, - selectedItems = readableListWithExplainer, - selectedIndex = list.indexOf(currentDistributor), - ) { index -> - if (list[index] == "None") { - sharedPrefs.dontAskForNotificationPermissions() - sharedPrefs.dontShowPushNotificationSelector() - PushDistributorHandler.forceRemoveDistributor(context) - } else { - PushDistributorHandler.saveDistributor(list[index]) - } + val selectedIndex = list.indexOf(currentDistributor).coerceAtLeast(0) + SettingsBlockTile( + icon = MaterialSymbols.CloudSync, + title = stringRes(R.string.push_server_title), + description = stringRes(R.string.push_server_explainer), + ) { + TextSpinner( + label = null, + placeholder = readableListWithExplainer[selectedIndex].title, + options = readableListWithExplainer, + onSelect = { index -> + if (list[index] == "None") { + sharedPrefs.dontAskForNotificationPermissions() + sharedPrefs.dontShowPushNotificationSelector() + PushDistributorHandler.forceRemoveDistributor(context) + } else { + PushDistributorHandler.saveDistributor(list[index]) + } + }, + modifier = Modifier.fillMaxWidth(), + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 04055ec777..10834421dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -164,6 +164,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsSc import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen @@ -326,6 +327,7 @@ fun BuildNavigation( composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) } composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } + composableFromEnd { NotificationSettingsScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 3d5ece6229..b9e4ae495e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -239,6 +239,8 @@ sealed class Route { @Serializable object CallSettings : Route() + @Serializable object NotificationSettings : Route() + @Serializable object Lists : Route() @Serializable data class MyPeopleListView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index bf15f61607..7fb17f1bee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -218,6 +218,12 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.Settings) }, ) SettingsDivider() + SettingsItem( + title = R.string.notification_settings, + icon = MaterialSymbols.Notifications, + onClick = { nav.nav(Route.NotificationSettings) }, + ) + SettingsDivider() SettingsItem( title = R.string.compose_settings, icon = MaterialSymbols.Edit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 9be0e54f6b..0d3a39e992 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -31,12 +31,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -46,14 +42,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType @@ -65,8 +58,6 @@ import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType -import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper -import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -97,7 +88,7 @@ fun SettingsScreen( }, ) { Column(Modifier.padding(it)) { - SettingsScreen(accountViewModel.settings.uiSettingsFlow, accountViewModel) + SettingsScreen(accountViewModel.settings.uiSettingsFlow) } } } @@ -111,10 +102,7 @@ fun SettingsScreenPreview() { } @Composable -fun SettingsScreen( - sharedPrefs: UiSettingsFlow, - accountViewModel: AccountViewModel? = null, -) { +fun SettingsScreen(sharedPrefs: UiSettingsFlow) { Column( Modifier .fillMaxSize() @@ -128,18 +116,11 @@ fun SettingsScreen( ShowImagePreviewChoice(sharedPrefs) ShowVideoPlaybackChoice(sharedPrefs) AutoplayVideosChoice(sharedPrefs) - if (BuildConfig.FLAVOR == "play") { - } ShowUrlPreviewChoice(sharedPrefs) ShowProfilePictureChoice(sharedPrefs) ImmersiveScrollingChoice(sharedPrefs) FeatureSetChoice(sharedPrefs) GalleryChoice(sharedPrefs) - PushNotificationSettingsRow(sharedPrefs) - if (accountViewModel != null) { - AlwaysOnNotificationServiceChoice(accountViewModel) - SplitNotificationsChoice(accountViewModel) - } } } @@ -489,86 +470,3 @@ fun SettingsRow( } } } - -@Composable -fun AlwaysOnNotificationServiceChoice(accountViewModel: AccountViewModel) { - val enabled by accountViewModel.account.settings.alwaysOnNotificationService - .collectAsStateWithLifecycle() - - SettingsRow( - R.string.always_on_notif_setting_title, - R.string.always_on_notif_setting_description, - ) { - Switch( - checked = enabled, - onCheckedChange = { - accountViewModel.account.settings.toggleAlwaysOnNotificationService() - }, - ) - } - - if (enabled) { - BatteryOptimizationBanner() - } -} - -@Composable -fun SplitNotificationsChoice(accountViewModel: AccountViewModel) { - val enabled by accountViewModel.account.settings.splitNotificationsEnabled - .collectAsStateWithLifecycle() - - SettingsRow( - R.string.split_notifications_setting_title, - R.string.split_notifications_setting_description, - ) { - Switch( - checked = enabled, - onCheckedChange = { - accountViewModel.account.settings.toggleSplitNotificationsEnabled() - }, - ) - } -} - -@Composable -fun BatteryOptimizationBanner() { - val context = LocalContext.current - val isExempt = - remember { - BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context) - } - - if (!isExempt) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringRes(R.string.battery_optimization_title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - text = stringRes(R.string.battery_optimization_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Button( - onClick = { - BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) - }, - ) { - Text(stringRes(R.string.battery_optimization_fix_now)) - } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt new file mode 100644 index 0000000000..8b1c463713 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 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.amethyst.ui.screen.loggedIn.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper +import com.vitorpamplona.amethyst.ui.components.HasPushNotificationProvider +import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun NotificationSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { TopBarWithBackButton(stringRes(id = R.string.notification_settings), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + if (HasPushNotificationProvider()) { + SettingsSection(R.string.notification_settings_section_push) { + PushNotificationProviderTile(accountViewModel.settings.uiSettingsFlow) + } + } + + SettingsSection(R.string.notification_settings_section_in_app) { + AlwaysOnServiceTile(accountViewModel) + SettingsDivider() + SplitByFollowsTile(accountViewModel) + } + } + } +} + +@Composable +private fun AlwaysOnServiceTile(accountViewModel: AccountViewModel) { + val enabled by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + + SwitchTile( + icon = MaterialSymbols.Notifications, + title = R.string.always_on_notif_setting_title, + description = R.string.always_on_notif_setting_description, + checked = enabled, + onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() }, + ) + + if (enabled) { + BatteryOptimizationBanner() + } +} + +@Composable +private fun SplitByFollowsTile(accountViewModel: AccountViewModel) { + val enabled by accountViewModel.account.settings.splitNotificationsEnabled + .collectAsStateWithLifecycle() + + SwitchTile( + icon = MaterialSymbols.Forum, + title = R.string.split_notifications_setting_title, + description = R.string.split_notifications_setting_description, + checked = enabled, + onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() }, + ) +} + +@Composable +private fun SwitchTile( + icon: MaterialSymbol, + @StringRes title: Int, + @StringRes description: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + SettingsControlRow( + icon = icon, + title = stringRes(title), + description = stringRes(description), + onClick = { onCheckedChange(!checked) }, + ) { + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun BatteryOptimizationBanner() { + val context = LocalContext.current + var isExempt by remember { + mutableStateOf(BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)) + } + + if (!isExempt) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringRes(R.string.battery_optimization_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringRes(R.string.battery_optimization_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Button( + onClick = { + BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) + isExempt = BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context) + }, + ) { + Text(stringRes(R.string.battery_optimization_fix_now)) + } + } + } + } +} + +@Preview +@Composable +fun NotificationSettingsScreenPreview() { + ThemeComparisonColumn { + NotificationSettingsScreen(mockAccountViewModel(), EmptyNav()) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d4dcea91c5..8e7d495a02 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1674,9 +1674,13 @@ Read-only user No reactions setup + Notifications + In-app notifications + Push notifications + Select a UnifiedPush App - Push Notification - From installed UnifiedPush apps + Push provider + Pick a UnifiedPush app to deliver notifications when Amethyst is closed. None Disables Push Notifications Uses app %1$s diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 5091181653..2f086def3f 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -52,4 +52,7 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) { } @Composable -fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {} +fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {} + +@Composable +fun HasPushNotificationProvider(): Boolean = false From e719d9ef00f7177166dd12ed8030d1920f76008e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 23:22:10 +0000 Subject: [PATCH 04/21] fix(imeta): accept floating-point dimensions so image space is reserved pre-load Primal-style clients emit "dim 317.0x498.0" in NIP-92 imeta tags. DimensionTag.parse called Int.parseInt on each component, threw NumberFormatException, and returned null. With dim==null and no cached aspect ratio, GifVideoView / UrlImageView built the container without an aspectRatio modifier, so the inline image collapsed to zero height and the post body looked empty until Coil delivered the bitmap. Parse each component as Double then truncate to Int. Adds DimensionTagTest covering integer, float, truncation, 0x0 and malformed inputs. https://claude.ai/code/session_01W1crao6Hwip8k5ByoLxVrc --- .../nip94FileMetadata/tags/DimensionTag.kt | 7 +- .../tags/DimensionTagTest.kt | 75 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTagTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt index 098957f44e..0ec356b6d3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt @@ -56,8 +56,11 @@ class DimensionTag( if (parts.size != 2) return null return try { - val width = parts[0].toInt() - val height = parts[1].toInt() + // Some clients (e.g. Primal) emit floating-point dimensions like "317.0x498.0" + // in NIP-92 imeta tags. Parse as Double and truncate to keep those tags usable + // for pre-load layout reservation. + val width = parts[0].toDouble().toInt() + val height = parts[1].toDouble().toInt() DimensionTag(width, height) } catch (e: Exception) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTagTest.kt new file mode 100644 index 0000000000..e97ecbf833 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTagTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 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.nip94FileMetadata.tags + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class DimensionTagTest { + @Test + fun parsesIntegerDimensions() { + val tag = DimensionTag.parse("317x498") + assertNotNull(tag) + assertEquals(317, tag.width) + assertEquals(498, tag.height) + } + + @Test + fun parsesFloatDimensionsFromPrimal() { + // Regression: kind:1 notes from Primal-style clients ship floating-point dims in + // their imeta tag (e.g. "dim 317.0x498.0"). Before this was tolerated the value + // parsed to null, the GIF/image container lost its aspectRatio modifier, and the + // post body collapsed to zero height until Coil delivered the bitmap. + val tag = DimensionTag.parse("317.0x498.0") + assertNotNull(tag) + assertEquals(317, tag.width) + assertEquals(498, tag.height) + } + + @Test + fun truncatesNonIntegerFloats() { + val tag = DimensionTag.parse("317.9x498.4") + assertNotNull(tag) + assertEquals(317, tag.width) + assertEquals(498, tag.height) + } + + @Test + fun rejectsZeroByZero() { + assertNull(DimensionTag.parse("0x0")) + } + + @Test + fun rejectsMalformed() { + assertNull(DimensionTag.parse("not-a-dim")) + assertNull(DimensionTag.parse("317")) + assertNull(DimensionTag.parse("317x")) + } + + @Test + fun aspectRatioMatchesPrimalGif() { + val tag = DimensionTag.parse("317.0x498.0") + assertNotNull(tag) + assertEquals(317f / 498f, tag.aspectRatio()) + } +} From 961c649901f7557396aca8f1958175baeeae113a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 00:58:36 +0000 Subject: [PATCH 05/21] feat: explain hashtag-limit hide reason in HiddenNote When a post is filtered out by the max-hashtag-per-post setting, the "Show Anyway" card previously displayed the generic "Post was muted or reported by" text with no avatars below it, which was confusing. Plumb a hasExcessiveHashtags flag and the configured limit through NoteComposeReportState and BlockReportChecker so HiddenNote can render a hashtag-specific message ("This post has more than N hashtags"). When both reports and the hashtag limit apply, show both reasons. --- .../vitorpamplona/amethyst/model/Account.kt | 6 +- .../amethyst/ui/note/BlankNote.kt | 76 ++++++++++++++----- .../amethyst/ui/note/BlockReportChecker.kt | 12 +-- .../ui/screen/loggedIn/AccountViewModel.kt | 16 ++-- amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 79 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a631b77d21..94825ee4f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3067,8 +3067,10 @@ class Account( fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors - private fun hasExcessiveHashtags(note: Note): Boolean { - val limit = settings.syncedSettings.security.maxHashtagLimit.value + fun maxHashtagLimit(): Int = settings.syncedSettings.security.maxHashtagLimit.value + + fun hasExcessiveHashtags(note: Note): Boolean { + val limit = maxHashtagLimit() return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index b00c1735a9..1d0483f4ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -107,6 +107,23 @@ fun HiddenNotePreview() { ) } +@Composable +@Preview +fun HiddenNoteExcessiveHashtagsPreview() { + ThemeComparisonColumn( + toPreview = { + HiddenNote( + reports = persistentSetOf(), + isHiddenAuthor = false, + hasExcessiveHashtags = true, + hashtagLimit = 8, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) {} + }, + ) +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun HiddenNote( @@ -115,8 +132,11 @@ fun HiddenNote( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, nav: INav, + hasExcessiveHashtags: Boolean = false, + hashtagLimit: Int = 0, onClick: () -> Unit, ) { + val hasReporters = isHiddenAuthor || reports.isNotEmpty() Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { Row( modifier = Modifier.padding(horizontal = 20.dp), @@ -127,26 +147,42 @@ fun HiddenNote( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(30.dp), ) { - Text( - text = stringRes(R.string.post_was_flagged_as_inappropriate_by), - color = Color.Gray, - ) - FlowRow(modifier = Modifier.padding(top = 10.dp)) { - if (isHiddenAuthor) { - UserPicture( - user = accountViewModel.userProfile(), - size = Size35dp, - nav = nav, - accountViewModel = accountViewModel, - ) - } - reports.forEach { - NoteAuthorPicture( - baseNote = it, - size = Size35dp, - accountViewModel = accountViewModel, - nav = nav, - ) + if (hasExcessiveHashtags) { + Text( + text = stringRes(R.string.post_was_hidden_due_to_too_many_hashtags, hashtagLimit), + color = Color.Gray, + textAlign = TextAlign.Center, + ) + } + + if (hasReporters || !hasExcessiveHashtags) { + Text( + text = stringRes(R.string.post_was_flagged_as_inappropriate_by), + color = Color.Gray, + modifier = + if (hasExcessiveHashtags) { + Modifier.padding(top = 10.dp) + } else { + Modifier + }, + ) + FlowRow(modifier = Modifier.padding(top = 10.dp)) { + if (isHiddenAuthor) { + UserPicture( + user = accountViewModel.userProfile(), + size = Size35dp, + nav = nav, + accountViewModel = accountViewModel, + ) + } + reports.forEach { + NoteAuthorPicture( + baseNote = it, + size = Size35dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt index b64b8e0a2f..aef0d62e86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlockReportChecker.kt @@ -77,11 +77,13 @@ fun WatchBlockAndReport( normalNote(isHidden.canPreview) } else { HiddenNote( - isHidden.relevantReports, - isHidden.isHiddenAuthor, - accountViewModel, - modifier, - nav, + reports = isHidden.relevantReports, + isHiddenAuthor = isHidden.isHiddenAuthor, + hasExcessiveHashtags = isHidden.hasExcessiveHashtags, + hashtagLimit = isHidden.hashtagLimit, + accountViewModel = accountViewModel, + modifier = modifier, + nav = nav, onClick = { showAnyway.value = true }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index f650d11a6c..fed5cb7746 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -514,6 +514,8 @@ class AccountViewModel( val canPreview: Boolean = true, val isHiddenAuthor: Boolean = false, val relevantReports: ImmutableSet = persistentSetOf(), + val hasExcessiveHashtags: Boolean = false, + val hashtagLimit: Int = 0, ) fun isNoteAcceptable( @@ -546,12 +548,16 @@ class AccountViewModel( // No need to process reports if nothing is wrong NoteComposeReportState(isPostHidden, isAcceptable = true, canPreview = true, isHiddenAuthor = false) } else { + val hashtagLimit = account.maxHashtagLimit() + val hasExcessiveHashtags = account.hasExcessiveHashtags(note) NoteComposeReportState( - isPostHidden, - newIsAcceptable, - newCanPreview, - false, - account.getRelevantReports(note).toImmutableSet(), + isPostHidden = isPostHidden, + isAcceptable = newIsAcceptable, + canPreview = newCanPreview, + isHiddenAuthor = false, + relevantReports = account.getRelevantReports(note).toImmutableSet(), + hasExcessiveHashtags = hasExcessiveHashtags, + hashtagLimit = hashtagLimit, ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d4dcea91c5..e99ad9b57c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ Show Anyway This post was hidden because it mentions your hidden users or words Post was muted or reported by + This post has more than %1$d hashtags Event is loading or can\'t be found in your relay list 👀 Channel Image From cc83c244278868f71fe71faa0c2adbc3a3c4d19b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 01:43:08 +0000 Subject: [PATCH 06/21] fix: dedupe public-channel rows in chatroom list updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the initial chatroom list picked a ChannelMetadataEvent or ChannelCreateEvent as the representative note for a public channel (because no ChannelMessageEvent had been seen yet), the arrival of a later ChannelMessageEvent caused updateListWith to append a second entry for the same channel — its match check only recognized old notes whose event was a ChannelMessageEvent. Two notes for the same channelId then produced the same PublicChannelLazyKey in the LazyColumn and crashed with IllegalArgumentException: Key was already used. Extract the channel id from any of the three public-chat event types when matching the existing entry so the new message replaces the placeholder instead of duplicating it. --- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index cba0f06476..c11283c657 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -30,6 +30,8 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent class ChatroomListKnownFeedFilter( @@ -111,7 +113,7 @@ class ChatroomListKnownFeedFilter( newRelevantPublicMessages.forEach { newNotePair -> var hasUpdated = false oldList.forEach { oldNote -> - val channelId = (oldNote.event as? ChannelMessageEvent)?.channelId() + val channelId = publicChannelIdOf(oldNote) if (newNotePair.key == channelId) { hasUpdated = true if ((newNotePair.value.createdAt() ?: 0L) > (oldNote.createdAt() ?: 0L)) { @@ -260,4 +262,18 @@ class ChatroomListKnownFeedFilter( } override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + + // Maps a note that represents a public chat row to its channel id. The + // representative note for a channel may be the channel's create event + // (id == channelId), a metadata update, or a message — match all three so + // an arriving ChannelMessageEvent replaces an existing placeholder + // metadata/create note for the same channel instead of duplicating it + // (which would yield the same LazyColumn key twice). + private fun publicChannelIdOf(note: Note): String? = + when (val event = note.event) { + is ChannelMessageEvent -> event.channelId() + is ChannelMetadataEvent -> event.channelId() + is ChannelCreateEvent -> event.id + else -> null + } } From 1f47a634709ede65e9c54230788cb7422b0c1339 Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Mon, 18 May 2026 13:50:47 +1000 Subject: [PATCH 07/21] feat(onchain-zaps): enable Send when typed name resolves via NIP-05 The Send-onchain-zap Recipient field already supported typing a NIP-05 or bare .bit name and tapping the resolved suggestion. If a user typed a fully-qualified name (e.g. "m@testls.bit" or "testls.bit") and hit Send without first tapping the suggestion, the eager resolver only ran decodePublicKeyAsHexOrNull(), which returns null for non-npub inputs, so the button stayed disabled with no inline feedback. Mirror the dropdown's existing NIP-05 / .bit resolution into the dialog state via UserSuggestionState.nip05ResolutionFlow, and use the resolved User's pubkey as one more fallback in resolvedRecipient. The flow is already debounced (300ms) and shared with the suggestion list, so this adds zero extra network traffic and zero new code paths -- just enables Send 300ms after a valid name resolves. Behaviour: - Typing "m@testls.bit" + waiting 300ms -> Send enables. - Typing "testls.bit" + waiting 300ms -> Send enables (same path the dropdown uses for bare .bit names; resolves as _@testls.bit). - Typing an npub or hex pubkey -> unchanged (already worked). - Typing an unrecognised string -> Send stays disabled, unchanged. - Tapping the suggestion before Send -> unchanged (selectedUser wins). No behaviour change anywhere outside the onchain zap dialog. --- .../ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index 2dc59bc595..ff4580305f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult @@ -158,10 +159,17 @@ fun OnchainZapSendDialog( accountViewModel.zapAmountChoices() } + // Mirror the dropdown's NIP-05 / Namecoin (.bit) resolution so Send can + // enable as soon as the typed name resolves, without forcing the user to + // tap the suggestion. Reuses the exact same path as the dropdown, so + // bare .bit names (e.g. testls.bit) and m@testls.bit both work. + val nip05Resolved by userSuggestions.nip05ResolutionFlow.collectAsStateWithLifecycle(initialValue = null) + val resolvedRecipient: HexKey? = recipientPubKey ?: selectedUser?.pubkeyHex ?: searchInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) } + ?: nip05Resolved?.pubkeyHex val amountSats = amountInput.trim().toLongOrNull() val canSend = !sending && From 69190e7810bc370b20891e945ff5bde94e2f7fc8 Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Mon, 18 May 2026 15:53:16 +1000 Subject: [PATCH 08/21] feat(profile): long-press to copy Nostr Address, Website, LN Address, identities, payment targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently the only profile fields with an explicit copy affordance are npub and nprofile (small ContentCopy IconButtons next to the keys). The other identifier-shaped fields rendered by DrawAdditionalInfo and DisplayLNAddress (NIP-05 Nostr Address, Website URL, lud16 Lightning Address, NIP-39 external identities, NIP-A3 payment targets) all have a tap action that *uses* the value (opens the URL, expands the zap sheet, etc.) but no way to actually copy the value itself short of selecting the underlying "View source" or opening the link and copying from the browser. Closes that gap with the platform-native long-press gesture. Adds a small reusable LongPressCopyText composable in ui/components/util/ that: - renders plain Text styled in primary colour (visual match for the previous ClickableTextPrimary), - wires onClick through Modifier.combinedClickable (so existing tap behaviour is preserved), - adds onLongClick to copy the supplied raw value to the system Clipboard and surface a Toast.makeText "Copied to clipboard" confirmation, - announces the long-press action to TalkBack via onLongClickLabel. The composable deliberately uses a plain Text + outer combinedClickable rather than an AnnotatedString with an inline LinkAnnotation.Clickable: an annotation-level click can't see a parent combinedClickable's long-press, because the parent's tap area sits above the annotation's hit-test region and would consume the tap before the annotation could fire it. Plain Text + outer modifier keeps both gestures behaving correctly. Wired into: - DisplayNip05ProfileStatus (NIP-05 Nostr Address) — tap opens the domain in the browser, long-press copies the full user@domain value. - The website row in DrawAdditionalInfo — tap opens the URL, long-press copies the *raw* website value as stored in the profile (the displayed form is stripped of the scheme + trailing slash for readability; the copy preserves the original). - The external-identity rows (Twitter, Mastodon, Telegram, GitHub) — tap opens the proof URL, long-press copies the identity handle. - DisplayLNAddress lud16 — tap toggles the zap sheet (unchanged), long-press copies the lud16 address. - PaymentTargetRow authority — tap opens the payto: URI, long-press copies the authority. The existing inline ContentCopy IconButtons on npub and nprofile are left in place; the long-press gesture is purely additive for the other rows. Two notable side-effects from the ClickableTextPrimary -> Text + combinedClickable rewrite: - Touch ripple feedback now appears on tap (combinedClickable's default indication). Previously the inline LinkAnnotation rendered no ripple. This is consistent with how other clickable rows in the app behave and provides better tap feedback. - The NIP-05 row no longer renders the click region as a styled inline span; the entire text behaves as one tap+long-press target. Visually identical for short addresses; for any address long enough to wrap, the click region now matches the visible bounds rather than the per-glyph bounds. Adds a new copied_to_clipboard string. Picks up the existing copy_to_clipboard string for the TalkBack long-press hint. --- .../ui/components/util/LongPressCopyText.kt | 100 ++++++++++++++++++ .../profile/header/DisplayLNAddress.kt | 9 +- .../profile/header/DrawAdditionalInfo.kt | 41 +++---- amethyst/src/main/res/values/strings.xml | 1 + 4 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/LongPressCopyText.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/LongPressCopyText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/LongPressCopyText.kt new file mode 100644 index 0000000000..1fa92b3b68 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/LongPressCopyText.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 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.amethyst.ui.components.util + +import android.widget.Toast +import androidx.compose.foundation.combinedClickable +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch + +/** + * Text composable that opens [onClick] on a tap and copies [copyValue] to the + * system clipboard on a long-press (with a Toast confirmation). + * + * This is the long-press-to-copy counterpart of [com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary]. + * It deliberately uses a plain [Text] + [combinedClickable] outer modifier + * rather than an inline `LinkAnnotation.Clickable` inside an `AnnotatedString`, + * because annotation-level clicks can't see a parent [combinedClickable]'s + * long-press: the parent's tap area sits above the annotation's hit-test + * region and would consume the tap before the annotation could fire it. + * + * @param displayText text shown to the user (may be a stripped form, e.g. + * "example.com" for a website value "https://example.com"). + * @param copyValue raw value placed on the clipboard on long-press + * (typically the full, unmodified profile field). + * @param onClick tap action — usually "open the link" or "expand zap UI". + * @param toastResId string resource shown via [Toast.LENGTH_SHORT] after the + * value is placed on the clipboard. Defaults to a generic + * "Copied to clipboard" message. + * @param onLongClickLabelResId accessibility label exposed to TalkBack for + * the long-press action. Defaults to "Copy to clipboard". + */ +@Composable +fun LongPressCopyText( + displayText: String, + copyValue: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + style: TextStyle = LocalTextStyle.current, + softWrap: Boolean = true, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, + toastResId: Int = R.string.copied_to_clipboard, + onLongClickLabelResId: Int = R.string.copy_to_clipboard, +) { + val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val longClickLabel = stringRes(onLongClickLabelResId) + + Text( + text = displayText, + color = color, + style = style, + softWrap = softWrap, + overflow = overflow, + maxLines = maxLines, + modifier = + modifier.combinedClickable( + onClick = onClick, + onLongClick = { + scope.launch { + clipboard.setText(copyValue) + Toast.makeText(context, stringRes(context, toastResId), Toast.LENGTH_SHORT).show() + } + }, + onLongClickLabel = longClickLabel, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index 4a039d1b1e..2b2ac59469 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -31,11 +31,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.InformationDialog -import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary +import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog @@ -88,9 +89,11 @@ fun DisplayLNAddress( Row(verticalAlignment = Alignment.CenterVertically) { LightningAddressIcon(modifier = Size16Modifier, tint = BitcoinOrange) - ClickableTextPrimary( - text = lud16, + LongPressCopyText( + displayText = lud16, + copyValue = lud16, onClick = { zapExpanded = !zapExpanded }, + overflow = TextOverflow.Ellipsis, modifier = Modifier .padding(top = 1.dp, bottom = 1.dp, start = 5.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 11cf5d4356..f6d2fd7d88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -59,10 +58,9 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.components.appendLink +import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.DrawPlayName @@ -237,8 +235,9 @@ fun DrawAdditionalInfo( modifier = Modifier.size(18.dp), ) - ClickableTextPrimary( - text = website.removePrefix("https://").removePrefix("http://").removeSuffix("/"), + LongPressCopyText( + displayText = website.removePrefix("https://").removePrefix("http://").removeSuffix("/"), + copyValue = website, onClick = { runCatching { if (website.contains("://")) { @@ -248,6 +247,7 @@ fun DrawAdditionalInfo( } } }, + overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(vertical = 1.dp, horizontal = 5.dp), ) } @@ -264,9 +264,11 @@ fun DrawAdditionalInfo( modifier = Modifier.size(18.dp), ) - ClickableTextPrimary( - text = identity.identity, + LongPressCopyText( + displayText = identity.identity, + copyValue = identity.identity, onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, + overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 5.dp), ) } @@ -343,21 +345,18 @@ fun DisplayNip05ProfileStatus( ObserveAndRenderNIP05VerifiedSymbol(nip05State, 2, Size15Modifier, accountViewModel) val uri = LocalUriHandler.current - val color = MaterialTheme.colorScheme.primary + val displayValue = nip05State.nip05.toDisplayValue() - Text( - text = - remember(nip05State) { - buildAnnotatedString { - appendLink(nip05State.nip05.toDisplayValue(), color) { - runCatching { uri.openUri("https://${nip05State.nip05.domain}") } - } - } - }, - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp), + LongPressCopyText( + displayText = displayValue, + copyValue = displayValue, + onClick = { + runCatching { uri.openUri("https://${nip05State.nip05.domain}") } + }, softWrap = true, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp), ) } } @@ -419,13 +418,15 @@ fun PaymentTargetRow(target: PaymentTarget) { fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, modifier = Modifier.padding(end = 4.dp), ) - ClickableTextPrimary( - text = target.authority, + LongPressCopyText( + displayText = target.authority, + copyValue = target.authority, onClick = { runCatching { uri.openUri("payto://${target.type}/${target.authority}") } }, + overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(vertical = 1.dp), ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d4dcea91c5..eb43873414 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1541,6 +1541,7 @@ Copy Stack Copy to clipboard + Copied to clipboard Copy nprofile to clipboard Copy npub to clipboard Share or Save From 543c4dfeb8f0233de21e5fd131aabaeaed1a5e67 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 18 May 2026 12:50:46 +0000 Subject: [PATCH 09/21] New Crowdin translations by GitHub Action --- .../src/main/res/values-nl-rNL/strings.xml | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 648a37ebdd..9522c51b1f 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -359,6 +359,8 @@ Blokkeren Verwijderen Blokkeren + Discussie dempen + Discussie dempen opheffen Rapporteren Verwijderen Niet meer tonen @@ -634,6 +636,16 @@ Ontvolgen Dempen Dempen opheffen + Kan genegeerd worden door clients die dit commando niet ondersteunen. + Uit de ruimte kicken? + %1$s wordt verwijderd van het audiokanaal en uit de deelnemerslijst. Ze kunnen opnieuw deelnemen als ze de link hebben. + Kicken + Spreker forceren te dempen? + Vraagt %1$s’s client om de microfoon te dempen. Sommige clients negeren dit commando mogelijk. + Forceren dempen + Annuleren + Actie mislukt + De actie kon niet worden voorbereid. Ruimte delen Minimaliseren Minimaliseren om te blijven luisteren @@ -1033,6 +1045,11 @@ Kan gesprek niet starten Kan gesprek niet accepteren Aanmaken oproepsessie mislukt + Toestemming nodig + Amethyst heeft toegang tot de microfoon nodig om een spraakoproep te starten. Schakel dit in bij de app-instellingen. + Amethyst heeft toegang tot camera en microfoon nodig om een video-oproep te starten. Schakel dit in bij de app-instellingen. + Instellingen openen + Annuleren Gespreksinstellingen Spraak- en videogesprekken inschakelen Wanneer uitgeschakeld, worden belknoppen verborgen en worden inkomende gesprekken stil genegeerd. @@ -1054,6 +1071,10 @@ Verbinden met inbox-relays… Altijd-aan meldingsdienst Houdt een persistente verbinding met je inbox-relays voor directe melding. Toont een permanente notificatie. Gebruikt meer batterij maar zorgt dat je nooit een bericht mist. + Meldingen splitsen per gevolgden + Toon twee meldings-tabbladen — Volgend (mensen die je volgt) en Iedereen. De ongelezen-indicator licht alleen op voor activiteit van mensen die je volgt. + Volgend + Iedereen Batterij-optimalisatie actief Android kan relay-verbindingen op de achtergrond beperken. Schakel batterij-optimalisatie uit voor Amethyst voor betrouwbare meldingen. Nu oplossen @@ -1078,14 +1099,24 @@ Waarschuwen bij rapportages van volgers Spamfilter Verbergt identieke berichten van onbekenden die 5 keer of vaker voorkomen + Client-tag toevoegen aan mijn events + Wanneer ingeschakeld voegt Amethyst een NIP-89 client-tag toe aan events die je publiceert. Waarschuwen bij rapportages Toont waarschuwing wanneer een bericht 5 of meer rapportages van je volgers heeft + Drempel voor rapportage-waarschuwing + Toont waarschuwing wanneer berichten of profielen dit aantal rapportages van mensen die je volgt bereiken Gevoelige inhoud tonen Toont waarschuwing wanneer auteur inhoud als gevoelig heeft gemarkeerd Maximum hashtags per bericht Verbergt berichten met meer hashtags dan deze limiet. Stel in op 0 om uit te schakelen. Berichten verbergen die community-regels schenden Verwijdert berichten uit community-feeds wanneer de community een NIP-9B regelsdocument publiceert en een event daartegen zou falen. Heeft geen effect wanneer een community geen gestructureerde regels heeft. + Filtervoorkeuren + Geblokkeerde inhoud + + Je hebt nog geen gebruikers geblokkeerd. + Geen accounts zijn in deze sessie als spam gemarkeerd. + Geen verborgen woorden. Voeg hieronder een woord toe om berichten met dat woord te verbergen. Nieuw reactie-symbool Geen reactietypes ingesteld. Houd ingedrukt om te wijzigen. Zapraiser @@ -1263,6 +1294,7 @@ Spammers Gedempt. Tik voor geluid Geluid aan. Tik voor dempen + Demping opheffen %d seconden terug %d seconden vooruit Picture-in-Picture @@ -1282,6 +1314,8 @@ Alleen volgers van de locatie zien dit bericht. Alleen hashtag-exclusief bericht Alleen volgers van de hashtag zien dit bericht. + Reageer op een website + Reageer op een externe bron %1$d min lezen Locatie laden… Geen locatiemachtigingen @@ -1378,6 +1412,9 @@ Geen Blossom-app gevonden. Installeer een lokale Blossom-app om dit bestand te bekijken. Verborgen woorden Nieuwe woorden of zinnen verbergen + Gedempte discussies + Geen gedempte discussies + Onbekende discussie · %1$s Profielfoto Profielfoto\'s tonen Selecteer een optie @@ -1607,6 +1644,12 @@ Start-tabbladen Kies welke tabbladen op het startscherm verschijnen. Wanneer slechts één tab actief is, wordt de tabbalk verborgen. Alles + Profiel-weergave + Kies welke secties en feeds op gebruikersprofielschermen verschijnen. Standaard zijn alle opties ingeschakeld. + Profielbadges + App-aanbevelingen + Ontvangen zaps-feed + Volgers-feed Reactierij Configureer welke reactieknoppen worden getoond, hun volgorde en of tellers worden weergegeven. Ingeschakeld @@ -1641,6 +1684,8 @@ Video downloaden naar je apparaat (verborgen bij livestreams) Picture-in-Picture Video in zwevend venster afspelen (verborgen als niet ondersteund) + Casten naar apparaat + Video casten naar een Chromecast-ontvanger op je wifi (verborgen bij lokale bestanden) Profielfoto van %1$s Relay %1$s Relay-lijst uitvouwen @@ -1759,6 +1804,18 @@ Git-repository: %1$s Web: Klonen: + Openen + Samengevoegd + Gesloten + Concept + Overzicht + Problemen + Patches & PRs + Over + Links + Beheerders + Onderwerpen + Persoonlijke fork Statische website: %1$s Root-site Bron: @@ -1856,8 +1913,11 @@ Favoriete feed-algoritmes Feed-algoritmes die je hier hebt gesterd, verschijnen als filterchips op de startfeed. Open Ontdekken om meer toe te voegen. Pin je favoriete algoritmes + Tik op “%1$s” hieronder om algoritmes te bekijken. + Tik op het %1$s naast een feed om hem hier te bewaren. Feeds toevoegen + Meer toevoegen… %1$s vragen voor een feed… Je favoriete feed-algoritmes vragen voor feeds… Je feed verwerken… @@ -2250,6 +2310,10 @@ Afspelen Auto + Casten naar apparaat + Casten stoppen + Casten naar… + Zoeken naar apparaten op je wifi… HLS-upload Publiceer multi-resolutie HLS naar je mediaserver @@ -2448,6 +2512,9 @@ Gebruikt een on-device AI-model om tekstcorrecties en toonwijzigingen voor te stellen. Getrackte uitzendingen Gebruik de tracked broadcaster bij het verzenden van events. Toont live voortgang en per-relay-status tijdens uitzenden. + Opstel-instellingen + Automatisch concepten aanmaken + Slaat automatisch een concept op wanneer je typt of de opsteller verlaat met onverzonden tekst en stuurt dit naar je privé outbox-relays. Gebruik dit Sluiten Corrigeren From 935ad84ac3544c0effeaebc9337b4bc16c6e39ec Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 18 May 2026 14:31:13 +0300 Subject: [PATCH 10/21] fix(desktop): fix release build ProGuard rules that crash app on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProGuard in release builds (packageReleaseDmg/Deb/Rpm) strips classes accessed via reflection, JNI, or service loading — causing multiple runtime failures: - Jackson ExceptionInInitializerError: enum constants stripped, NPE in MapperConfig.collectFeatureDefaults() (#2929, 5000 sats bounty) - JNA/VLCJ: static methods stripped, SIGABRT in native callbacks - secp256k1/SQLite/kmp-tor: JNI loader classes stripped - Coil/okhttp/okio: image loading and networking broken - Kotlin metadata stripped: Jackson can't call default constructors Changes: - Upgrade ProGuard 7.7.0 → 7.9.1 (Kotlin 2.3 metadata support) - Disable optimization (-dontoptimize) to prevent bytecode rewriting that produces VerifyError (Guardsquare/proguard#460) - Add -keep rules for all JNI/reflection-dependent libraries - Keep Kotlin @Metadata annotations for Jackson deserialization - Suppress Kotlin 2.3 compile-time stub warnings Tested: release DMG builds and launches without crash on macOS. Closes #2929 Co-Authored-By: Claude Opus 4.6 (1M context) --- desktopApp/build.gradle.kts | 14 ++++++++- desktopApp/compose-rules.pro | 16 ++++++++++ .../service/media/MacOsVlcDiscoverer.kt | 29 +++++++++++++++++-- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0ec8f6836e..61485c6e68 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -91,6 +91,9 @@ compose.desktop { jvmArgs += "-Xmx2g" + // VLC plugin path fallback — used if JNA setenv and bundled discovery both fail + jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins" + // Forward platform-preview overrides from the gradle invocation to the // launched app's JVM so `./gradlew :desktopApp:run -Damethyst.platform=GNOME` // works in addition to the env-var form (`AMETHYST_PLATFORM=GNOME`). @@ -101,7 +104,15 @@ compose.desktop { nativeDistributions { appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) - modules("java.management") // Required by kmp-tor TorRuntime + // Output of ./gradlew suggestRuntimeModules (+ java.management already present) + modules( + "java.instrument", // Runtime instrumentation (agent/profiler hooks) + "java.management", // Required by kmp-tor TorRuntime + "java.prefs", // java.util.prefs (desktop persistence) + "java.sql", // JDBC metadata (Jackson, SQLite driver) + "jdk.security.auth", // JAAS authentication callbacks + "jdk.unsupported", // sun.misc.Unsafe (VLCJ ByteBufferFactory) + ) packageName = "Amethyst" packageVersion = appVersion @@ -143,6 +154,7 @@ compose.desktop { // whose declared return type the JVM verifier rejects (R8 doesn't hit // this — it generates bridges differently from ProGuard). buildTypes.release.proguard { + version.set("7.9.1") // Kotlin 2.3 metadata support configurationFiles.from(project.file("compose-rules.pro")) } } diff --git a/desktopApp/compose-rules.pro b/desktopApp/compose-rules.pro index bb824643d1..8241422ed6 100644 --- a/desktopApp/compose-rules.pro +++ b/desktopApp/compose-rules.pro @@ -96,6 +96,16 @@ native ; } +# kmp-tor — loads native Tor daemon via JNI reflection +-keep class io.matthewnelson.** { *; } + +# Coil image loader — uses ServiceLoader for decoder/fetcher registration +-keep class coil3.** { *; } + +# OkHttp/Okio — platform detection and I/O via reflection +-keep class okhttp3.** { *; } +-keep class okio.** { *; } + # ============================================================================ # Optimize sub-pass — disable the one that produces invalid okio bytecode # ============================================================================ @@ -185,3 +195,9 @@ # to detect logging. We ship slf4j-nop; keep it intact so detection succeeds. -keep class org.slf4j.** { *; } -dontwarn org.slf4j.** + +# ============================================================================ +# Kotlin 2.3 stdlib stubs — compile-time classes with no JVM runtime class +# ============================================================================ +-dontwarn kotlin.concurrent.atomics.** +-dontwarn kotlin.jvm.internal.EnhancedNullability diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt index d7f9c02f2f..58d7f9fa54 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.service.media +import com.sun.jna.Function import com.sun.jna.NativeLibrary -import uk.co.caprica.vlcj.binding.lib.LibC import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy @@ -36,6 +36,14 @@ class MacOsVlcDiscoverer : arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"), arrayOf("%s/plugins"), ) { + /** Plugin path discovered during [setPluginPath], available after discovery. */ + var discoveredPluginPath: String? = null + private set + + /** Whether [setPluginPath] successfully set the process env var. */ + var envVarSet: Boolean = false + private set + override fun supported(): Boolean { val os = System.getProperty("os.name").lowercase() return "mac" in os @@ -52,5 +60,22 @@ class MacOsVlcDiscoverer : return true } - override fun setPluginPath(pluginPath: String?): Boolean = LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0 + override fun setPluginPath(pluginPath: String?): Boolean { + if (pluginPath == null) return false + discoveredPluginPath = pluginPath + return try { + // Call setenv directly via JNA Function API. This bypasses vlcj's + // LibC interface binding which fails on macOS 13+ because dlsym + // can't resolve the versioned symbol `setenv$3b99ba0d`. + val setenv = Function.getFunction("c", "setenv") + val result = setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0 + envVarSet = result + result + } catch (_: Throwable) { + // JNA Function call also failed — VlcjPlayerPool will use + // --plugin-path factory arg as fallback. + envVarSet = false + false + } + } } From de5dc34cb7155f50bdad44c48d8aba85642fa89a Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 18 May 2026 15:16:23 +0300 Subject: [PATCH 11/21] fix(desktop): fix macOS VLC bundled discovery and video rendering MacOsVlcDiscoverer.setPluginPath() called LibC.INSTANCE.setenv() via JNA, but macOS 13+ uses versioned symbols (setenv$3b99ba0d) that JNA can't resolve, breaking all video playback without system VLC. Changes: - Replace LibC.setenv with direct JNA Function.getFunction("c","setenv") call that bypasses the problematic interface binding - Store discoveredPluginPath for --plugin-path factory arg fallback - Add -Dvlc.plugin.path JVM property as ultimate fallback - Delete stale VLC plugin cache on macOS before factory creation - Pass --plugin-path to audio factory too when env var fails - Add jdk.unsupported module to jlink (VLCJ ByteBufferFactory needs sun.misc.Unsafe for video frame buffer allocation) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/service/media/VlcjPlayerPool.kt | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt index 18e08c306d..50ea042e58 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt @@ -55,6 +55,9 @@ object VlcjPlayerPool { private val idleThumbPlayers = ConcurrentLinkedQueue() private const val MAX_THUMB_POOL_SIZE = 2 + // Cached plugin path for audio factory creation (set during init) + private var cachedPluginPath: String? = null + // Audio player pool (shared factory with --no-video) private var audioFactory: MediaPlayerFactory? = null private val allAudioPlayers = mutableListOf() @@ -76,12 +79,13 @@ object VlcjPlayerPool { return try { // Try bundled VLC first, then fall through to system VLC + val macOsDiscoverer = MacOsVlcDiscoverer() val discovery = try { val nd = NativeDiscovery( BundledVlcDiscoverer(), - MacOsVlcDiscoverer(), + macOsDiscoverer, ) val found = nd.discover() if (found) { @@ -99,7 +103,34 @@ object VlcjPlayerPool { val systemDiscovery = NativeDiscovery().discover() println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}") } - val f = MediaPlayerFactory("--no-xlib") + + // Delete stale VLC plugin cache on macOS to avoid spam warnings + if ("mac" in System.getProperty("os.name").lowercase()) { + try { + val cacheDir = java.io.File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc") + cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() } + } catch (_: Throwable) { + // Best-effort cache cleanup + } + } + + // Build factory args — add --plugin-path fallback if env var wasn't set + val factoryArgs = mutableListOf("--no-xlib") + if (!macOsDiscoverer.envVarSet) { + val pluginPath = + macOsDiscoverer.discoveredPluginPath + ?: System.getProperty("vlc.plugin.path") + ?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" } + if (pluginPath != null) { + factoryArgs += "--plugin-path=$pluginPath" + println("VLC: using --plugin-path fallback: $pluginPath") + } + } + + cachedPluginPath = macOsDiscoverer.discoveredPluginPath + ?: System.getProperty("vlc.plugin.path") + + val f = MediaPlayerFactory(*factoryArgs.toTypedArray()) factory = f available.set(true) println("VLC: MediaPlayerFactory created successfully") @@ -184,7 +215,9 @@ object VlcjPlayerPool { val af = audioFactory ?: try { - MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it } + val audioArgs = mutableListOf("--no-video", "--no-xlib") + cachedPluginPath?.let { audioArgs += "--plugin-path=$it" } + MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it } } catch (_: Throwable) { return null } From 67fc5608ccc5163bff1a869f8bdcd7c3c5a2ba11 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 18 May 2026 16:54:19 +0200 Subject: [PATCH 12/21] sonar fixes --- .../amethyst/service/okhttp/SurgeDnsStore.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt index bfbaa0ed54..34fc03c772 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/SurgeDnsStore.kt @@ -77,7 +77,7 @@ class SurgeDnsStore( readRecords(file) } catch (t: Throwable) { Log.w(TAG) { "Dropping corrupt DNS cache blob: ${t.message}" } - file.delete() + if (!file.delete()) Log.w(TAG) { "Failed to delete corrupt DNS cache blob at ${file.path}" } return } // restore() uses putIfAbsent and never marks dirty, so we deliberately do NOT clear the @@ -104,8 +104,8 @@ class SurgeDnsStore( file.parentFile?.mkdirs() writeRecords(tmp, records) if (!tmp.renameTo(file)) { - file.delete() - if (!tmp.renameTo(file)) { + // If delete fails the second rename will fail too; skip straight to the copy fallback. + if (!file.delete() || !tmp.renameTo(file)) { tmp.copyTo(file, overwrite = true) } } @@ -117,13 +117,13 @@ class SurgeDnsStore( } finally { // Cleans up after both happy paths (copyTo fallback) and failure paths (writeRecords // crashed partway, leaving a partial blob) so a corrupt tmp can't accumulate. - if (tmp.exists()) tmp.delete() + if (tmp.exists() && !tmp.delete()) Log.w(TAG) { "Failed to delete DNS cache tmp file at ${tmp.path}" } } } /** Force-clear the on-disk cache. Useful for diagnostics or when the user wipes data. */ fun clear() { - file.delete() + if (file.exists() && !file.delete()) Log.w(TAG) { "Failed to clear DNS cache blob at ${file.path}" } } private fun writeRecords( From fcf704a3c683fcde7c3aa80276eb1acac240218a Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 17 May 2026 11:22:27 +0200 Subject: [PATCH 13/21] fix(quartz): make RelayAuthenticator authStatus thread-safe (#2946) OkHttp dispatches WebSocket callbacks on one thread per relay socket, so RelayAuthenticator's plain LinkedHashMap was mutated concurrently from many threads during connection storms. When a bucket crossed HashMap's TREEIFY_THRESHOLD the racing treeify corrupted internal state and threw ClassCastException: LinkedHashMap$Entry cannot be cast to HashMap$TreeNode from onDisconnected. --- .../relay/client/auth/RelayAuthenticator.kt | 38 +++++- .../auth/RelayAuthenticatorConcurrencyTest.kt | 126 ++++++++++++++++++ 2 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index cce594855a..846469de0b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -36,6 +36,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi interface IAuthStatus { fun hasFinishedAuthentication(relay: NormalizedRelayUrl): Boolean @@ -45,12 +47,36 @@ object EmptyIAuthStatus : IAuthStatus { override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = true } +@OptIn(ExperimentalAtomicApi::class) class RelayAuthenticator( val client: INostrClient, val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), val signWithAllLoggedInUsers: suspend (EventTemplate) -> List, ) : IAuthStatus { - private val authStatus = mutableMapOf() + // Connection callbacks fire on the per-relay OkHttp dispatcher thread, so + // this state is mutated concurrently — copy-on-write under AtomicReference. + private val authStatus: AtomicReference> = + AtomicReference(emptyMap()) + + private fun putAuthStatus( + relay: NormalizedRelayUrl, + status: RelayAuthStatus, + ) { + while (true) { + val current = authStatus.load() + val next = current + (relay to status) + if (authStatus.compareAndSet(current, next)) return + } + } + + private fun removeAuthStatus(relay: NormalizedRelayUrl) { + while (true) { + val current = authStatus.load() + if (relay !in current) return + val next = current - relay + if (authStatus.compareAndSet(current, next)) return + } + } private val clientListener = object : RelayConnectionListener { @@ -66,11 +92,11 @@ class RelayAuthenticator( } override fun onConnecting(relay: IRelayClient) { - authStatus[relay.url] = RelayAuthStatus() + putAuthStatus(relay.url, RelayAuthStatus()) } override fun onDisconnected(relay: IRelayClient) { - authStatus.remove(relay.url) + removeAuthStatus(relay.url) } } @@ -82,7 +108,7 @@ class RelayAuthenticator( val ev = RelayAuthEvent.build(relay.url, msg.challenge) signWithAllLoggedInUsers(ev).forEach { authEvent -> // only send replies to new challenges to avoid infinite loop: - if (authStatus[relay.url]?.saveAuthSubmission(authEvent) == true) { + if (authStatus.load()[relay.url]?.saveAuthSubmission(authEvent) == true) { relay.sendIfConnected(AuthCmd(authEvent)) } } @@ -94,12 +120,12 @@ class RelayAuthenticator( msg: OkMessage, ) { // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. - if (authStatus[relay.url]?.checkAuthResults(msg.eventId, msg.success) == true) { + if (authStatus.load()[relay.url]?.checkAuthResults(msg.eventId, msg.success) == true) { client.syncFilters(relay) } } - override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = authStatus[relay]?.hasFinishedAllAuths() != false + override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = authStatus.load()[relay]?.hasFinishedAllAuths() != false init { Log.d("RelayAuthenticator", "Init, Subscribe") diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt new file mode 100644 index 0000000000..67fa00c2b7 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 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.nip01Core.relay.client.auth + +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlin.test.Test + +/** + * Reproduces issue #2946 — `ClassCastException: LinkedHashMap$Entry cannot be + * cast to HashMap$TreeNode` thrown from + * `RelayAuthenticator$clientListener.onDisconnected`. + * + * OkHttp dispatches WebSocket callbacks on one thread per relay, so when many + * relays connect/disconnect simultaneously the listener's internal map is + * mutated concurrently. Once a bucket exceeds the HashMap TREEIFY_THRESHOLD (8) + * the concurrent treeification corrupts internal state. + * + * On the buggy code this test fails non-deterministically with a + * `ClassCastException` (or `ConcurrentModificationException` / + * `NullPointerException`). After the fix it must pass cleanly every run. + */ +class RelayAuthenticatorConcurrencyTest { + private class CapturingClient( + private val delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + @Volatile var captured: RelayConnectionListener? = null + + override fun addConnectionListener(listener: RelayConnectionListener) { + captured = listener + } + } + + private class FakeRelayClient( + override val url: NormalizedRelayUrl, + ) : IRelayClient { + override fun connect() = Unit + + override fun needsToReconnect() = false + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit + + override fun isConnected() = false + + override fun sendOrConnectAndSync(cmd: Command) = Unit + + override fun sendIfConnected(cmd: Command) = Unit + + override fun disconnect() = Unit + } + + @Test + fun concurrentConnectingAndDisconnecting_doesNotCorruptInternalState() { + runBlocking { + // The race only fires while the underlying HashMap is structurally + // growing — rehashing and bucket treeification. Once the map reaches + // its steady-state size, put/remove on existing keys touch a single + // node and won't reproduce. So drive many short "burst" cycles, each + // starting from an empty map and growing it past + // MIN_TREEIFY_CAPACITY (64) under concurrent load. + repeat(50) { burst -> + val client = CapturingClient() + val authenticator = + RelayAuthenticator( + client = client, + signWithAllLoggedInUsers = { emptyList() }, + ) + val listener = + client.captured + ?: error("RelayAuthenticator did not register a listener") + + val relays = + (0 until 256).map { + FakeRelayClient(NormalizedRelayUrl("wss://relay-$burst-$it.example/")) + } + + withContext(Dispatchers.IO) { + (0 until 64) + .map { workerId -> + async { + // Each worker walks the relay set, connecting and + // disconnecting. Connects grow the map (rehash / + // treeify); disconnects shrink it; concurrent reads + // run alongside. + relays.forEachIndexed { idx, relay -> + if ((workerId + idx) and 1 == 0) { + listener.onConnecting(relay) + } else { + listener.onDisconnected(relay) + } + authenticator.hasFinishedAuthentication(relay.url) + } + } + }.awaitAll() + } + } + } + } +} From ee18490761a65a944e59700b018985a50f503de2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 19:16:32 +0000 Subject: [PATCH 14/21] fix: pad GitRepositoryOverview content to clear top/bottom bars The Overview tab is a plain vertical-scroll Column inside DisappearingScaffold, which lays content under the top app bar + tab row and bottom nav. The Issues and Patches tabs go through RefresheableFeedView and already consume LocalDisappearingScaffoldPadding internally, but the Overview tab did not, so its top and bottom were hidden behind the surrounding UI. --- .../ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt index e53b7b54b1..9020fb4384 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture @@ -72,11 +73,13 @@ fun GitRepositoryOverview( accountViewModel: AccountViewModel, nav: INav, ) { + val scaffoldPadding = LocalDisappearingScaffoldPadding.current Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) + .padding(scaffoldPadding) .padding(horizontal = 12.dp, vertical = 16.dp), verticalArrangement = SectionSpacing, ) { From 012dae31d0e593f6d55b47fb5ac59eab675ad908 Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Mon, 18 May 2026 14:07:34 +1000 Subject: [PATCH 15/21] feat(onchain-zaps): inline Namecoin resolution indicator + result row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local-cache suggestion dropdown can momentarily show a stale match when the user types a .bit identifier. For example, after resolving "m@testls.bit" earlier in the session, typing the bare host "testls.bit" causes findUsersStartingWith() to return the cached m@... profile first; the correct _@testls.bit profile only appears a few seconds later when ElectrumX resolution finishes. There is no visual hint that a Namecoin lookup is in flight, and on hard failures (timeout, malformed record, no nostr field, etc.) the user gets no feedback at all. This adds a dedicated NamecoinResolutionRow composable mounted between the recipient field and the local-cache dropdown: - "Resolving on Namecoin…" spinner while the on-chain lookup is in flight (after a 300 ms debounce that matches the dropdown's own debounce). - On success, a tappable row showing the resolved profile with a distinct "Namecoin" badge (MaterialSymbols.Link), so the user can pick the on-chain-verified profile unambiguously. - On failure, a single explanatory error line covering all NamecoinResolveOutcome variants the resolver already produces: NameNotFound, NoNostrField, MalformedRecord (with the underlying parser error verbatim), ServersUnreachable, InvalidIdentifier and Timeout. State is held in the shared NamecoinResolveState sealed class already used by NamecoinNameService and the desktop SearchScreen — no new state model is introduced. A small mapOutcomeToResolveState() helper mirrors the same wording desktop ships, so all Namecoin surfaces produce the same diagnostic string for the same outcome. The row owns its own LaunchedEffect keyed on the typed query, so it cancels in-flight lookups whenever the user keeps typing, and it unmounts cleanly once a recipient is selected. It uses the existing Amethyst.instance.namecoinResolver instance, so no DI plumbing or new network calls beyond what was already wired up for .bit resolution. The dropdown is left untouched: local-cache results are still valid hits (just not necessarily the *intended* on-chain identity), so they remain available below the new row. Pure helpers (looksLikeNamecoinIdentifier + the outcome-to-state mapper) are covered by unit tests in NamecoinResolutionRowTest. --- .../loggedIn/wallet/NamecoinResolutionRow.kt | 321 ++++++++++++++++++ .../loggedIn/wallet/OnchainZapSendDialog.kt | 12 + .../wallet/NamecoinResolutionRowTest.kt | 164 +++++++++ 3 files changed, 497 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRow.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRowTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRow.kt new file mode 100644 index 0000000000..8709b21734 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRow.kt @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2025 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.amethyst.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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 +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +/** + * Translate a [NamecoinResolveOutcome] from the quartz resolver into the + * shared [NamecoinResolveState] used elsewhere in the app (e.g. the + * desktop `SearchScreen`'s inline Namecoin lookup row). + * + * Mirrors the wording desktop already ships so the same identifier + * produces the same diagnostic string regardless of which surface + * triggered the lookup. Callers are expected to handle + * [NamecoinResolveOutcome.Success] separately (it needs a [User] + * lookup through [com.vitorpamplona.amethyst.model.LocalCache], which + * this helper has no access to). + */ +fun mapOutcomeToResolveState(outcome: NamecoinResolveOutcome): NamecoinResolveState = + when (outcome) { + is NamecoinResolveOutcome.Success -> + // Success is intentionally NOT handled here — callers must + // resolve the pubkey through LocalCache first. + error("mapOutcomeToResolveState called with Success outcome; resolve via LocalCache instead") + + is NamecoinResolveOutcome.NameNotFound -> NamecoinResolveState.NotFound + + is NamecoinResolveOutcome.NoNostrField -> + NamecoinResolveState.Error("${outcome.name} is registered but has no Nostr pubkey") + + is NamecoinResolveOutcome.MalformedRecord -> + // Surface the parser detail verbatim so the publisher of the + // broken record can locate the bad byte + // (kotlinx.serialization includes a column number). + NamecoinResolveState.Error("${outcome.name} record is malformed: ${outcome.error}") + + is NamecoinResolveOutcome.ServersUnreachable -> + NamecoinResolveState.Error("ElectrumX servers unreachable — check your connection or try again") + + is NamecoinResolveOutcome.InvalidIdentifier -> + NamecoinResolveState.Error("Invalid Namecoin identifier") + + NamecoinResolveOutcome.Timeout -> + NamecoinResolveState.Error("Resolution timed out — servers may be slow, try again") + } + +/** + * Lightweight syntactic check: does this look like something we should + * route to Namecoin? Mirrors [com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver.isNamecoinIdentifier] + * but tolerates a leading `@` (matches the dropdown's [com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState.userSearchTermOrNull]). + */ +fun looksLikeNamecoinIdentifier(raw: String): Boolean { + val trimmed = raw.trim().removePrefix("@").lowercase() + if (trimmed.length < 5) return false + return trimmed.endsWith(".bit") || + trimmed.contains("@") && trimmed.substringAfter('@').endsWith(".bit") +} + +/** + * Inline Namecoin resolution indicator + result row, sandwiched between + * the recipient text field and the local-cache suggestion dropdown in + * [OnchainZapSendDialog]. + * + * Behaviour: + * - Renders nothing when [searchInput] is not a `.bit` identifier. + * - Shows a small spinner row ("Resolving on Namecoin…") while the + * ElectrumX lookup is in flight (after a 300 ms debounce to match + * the dropdown's own debounce). + * - On success, shows the resolved user as a tappable row with a + * `MaterialSymbols.Link` badge labelled "Namecoin"; tapping calls + * [onUserResolved]. + * - On failure, shows a single explanatory line in the error colour. + * + * State is held in [NamecoinResolveState] (the same sealed class the + * desktop `SearchScreen` and `NamecoinNameService` already use) so this + * row stays in lockstep with the rest of the app's Namecoin UI. + * + * The composable is intentionally self-contained: it owns its own + * [LaunchedEffect] keyed on [searchInput], so it cancels in-flight + * lookups whenever the user keeps typing. + */ +@Composable +fun NamecoinResolutionRow( + searchInput: String, + accountViewModel: AccountViewModel, + onUserResolved: (User) -> Unit, +) { + val trimmed = remember(searchInput) { searchInput.trim().removePrefix("@") } + if (!looksLikeNamecoinIdentifier(trimmed)) return + + var state by remember { mutableStateOf(null) } + + LaunchedEffect(trimmed) { + // Match UserSuggestionState's 300 ms debounce so we don't fire a + // lookup on every keystroke. + delay(300) + state = NamecoinResolveState.Loading + val outcome = + withContext(Dispatchers.IO) { + runCatching { + Amethyst.instance.namecoinResolver.resolveDetailed(trimmed) + }.getOrElse { + NamecoinResolveOutcome.ServersUnreachable( + it.message ?: it::class.simpleName ?: "Lookup error", + ) + } + } + state = + when (outcome) { + is NamecoinResolveOutcome.Success -> NamecoinResolveState.Resolved(outcome.result) + else -> mapOutcomeToResolveState(outcome) + } + } + + Spacer(Modifier.size(8.dp)) + when (val s = state) { + null, NamecoinResolveState.Loading -> ResolvingChip(trimmed) + is NamecoinResolveState.Resolved -> ResolvedRow(trimmed, s, accountViewModel, onUserResolved) + NamecoinResolveState.NotFound -> FailedRow("No record for $trimmed on Namecoin.") + is NamecoinResolveState.Error -> FailedRow(s.message) + } +} + +@Composable +private fun ResolvingChip(query: String) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Resolving $query on Namecoin…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun ResolvedRow( + query: String, + state: NamecoinResolveState.Resolved, + accountViewModel: AccountViewModel, + onUserResolved: (User) -> Unit, +) { + // Look up the User in the same cache the rest of the app uses, exactly + // the way desktop's SearchScreen does. Falls back to a malformed-record + // error row if the pubkey somehow fails the hex shape check. + val user = + remember(state.result.pubkey) { + accountViewModel.account.cache.checkGetOrCreateUser(state.result.pubkey) + } + if (user == null) { + FailedRow( + "${state.result.namecoinName} record is malformed: " + + "pubkey ${state.result.pubkey} is not a valid hex key", + ) + return + } + + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = + Modifier + .fillMaxWidth() + .clickable { onUserResolved(user) }, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + UserPicture( + userHex = user.pubkeyHex, + size = 32.dp, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = query, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + NamecoinBadge() + } + } +} + +@Composable +private fun NamecoinBadge() { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = MaterialSymbols.Link, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(14.dp), + ) + Text( + text = "Namecoin", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +@Composable +private fun FailedRow(message: String) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(18.dp), + ) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.background(MaterialTheme.colorScheme.errorContainer), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index ff4580305f..8199baf54d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -414,6 +414,18 @@ private fun RecipientSection( modifier = Modifier.fillMaxWidth(), ) + // Inline Namecoin lookup feedback. Local-cache suggestions can race + // ahead of the on-chain resolution (especially when the user has + // resolved a sibling `user@.bit` earlier in the session and the + // current query is the bare host), so we surface the in-flight state + + // the eventual on-chain match in its own row, distinct from the + // generic dropdown. Failures are surfaced here too. + NamecoinResolutionRow( + searchInput = searchInput, + accountViewModel = accountViewModel, + onUserResolved = onSelectUser, + ) + if (searchInput.length > 2) { ShowUserSuggestionList( userSuggestions = userSuggestions, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRowTest.kt new file mode 100644 index 0000000000..2554afbdaf --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRowTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 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.amethyst.ui.screen.loggedIn.wallet + +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class NamecoinResolutionRowTest { + // ── looksLikeNamecoinIdentifier ──────────────────────────────────────── + + @Test + fun `bare bit hostname is a namecoin identifier`() { + assertTrue(looksLikeNamecoinIdentifier("testls.bit")) + assertTrue(looksLikeNamecoinIdentifier("Example.BIT")) + } + + @Test + fun `user at bit hostname is a namecoin identifier`() { + assertTrue(looksLikeNamecoinIdentifier("m@testls.bit")) + assertTrue(looksLikeNamecoinIdentifier("ALICE@example.BIT")) + } + + @Test + fun `leading at sign is tolerated like the dropdown does`() { + assertTrue(looksLikeNamecoinIdentifier("@testls.bit")) + assertTrue(looksLikeNamecoinIdentifier("@m@testls.bit")) + } + + @Test + fun `dns nip05 is not a namecoin identifier`() { + assertFalse(looksLikeNamecoinIdentifier("alice@example.com")) + assertFalse(looksLikeNamecoinIdentifier("example.com")) + } + + @Test + fun `npub-shaped input is not a namecoin identifier`() { + assertFalse(looksLikeNamecoinIdentifier("npub1w90qq8jq8x0z6nyz3vqgsk9vnp0w9p4ldwc0wq4xv2n7v8jch2nq3p6wrx")) + } + + @Test + fun `short or empty input is not a namecoin identifier`() { + // Below the minimum length threshold the dropdown also uses + // (UserSuggestionState.userSearchTermOrNull requires >2 chars). + assertFalse(looksLikeNamecoinIdentifier("")) + assertFalse(looksLikeNamecoinIdentifier(".bit")) + } + + @Test + fun `single-label bit name is still considered a namecoin identifier`() { + // Namecoin allows single-character labels; "a.bit" is a valid + // (if expensive) registration. Don't filter it out client-side. + assertTrue(looksLikeNamecoinIdentifier("a.bit")) + } + + @Test + fun `bit substring elsewhere does not trigger`() { + // "habit" or "rabbit.example.com" shouldn't match. + assertFalse(looksLikeNamecoinIdentifier("rabbit.example.com")) + assertFalse(looksLikeNamecoinIdentifier("ihabit")) + } + + @Test + fun `at without bit suffix does not match`() { + // "foo@bar" with no .bit on the right side should not match. + assertFalse(looksLikeNamecoinIdentifier("foo@bar")) + assertFalse(looksLikeNamecoinIdentifier("foo@bar.com")) + } + + // ── mapOutcomeToResolveState ─────────────────────────────────────────── + // Reuses the shared NamecoinResolveState already used by + // NamecoinNameService and the desktop SearchScreen so all surfaces + // produce the same diagnostic strings for the same outcome. + + @Test + fun `name not found maps to NotFound`() { + val state = mapOutcomeToResolveState(NamecoinResolveOutcome.NameNotFound("d/testls")) + assertSame(NamecoinResolveState.NotFound, state) + } + + @Test + fun `no nostr field maps to Error and mentions the name`() { + val state = mapOutcomeToResolveState(NamecoinResolveOutcome.NoNostrField("d/noname")) + require(state is NamecoinResolveState.Error) + assertTrue(state.message.contains("d/noname")) + assertTrue(state.message.contains("Nostr")) + } + + @Test + fun `malformed record preserves underlying parser detail`() { + val state = + mapOutcomeToResolveState( + NamecoinResolveOutcome.MalformedRecord( + "d/broken", + "Unfinished JSON term at EOF at line 1, column 474", + ), + ) + require(state is NamecoinResolveState.Error) + assertTrue(state.message.contains("d/broken")) + assertTrue(state.message.contains("Unfinished JSON")) + } + + @Test + fun `servers unreachable maps to a generic Error`() { + val state = + mapOutcomeToResolveState(NamecoinResolveOutcome.ServersUnreachable("Connection refused")) + require(state is NamecoinResolveState.Error) + assertTrue(state.message.contains("ElectrumX")) + } + + @Test + fun `invalid identifier maps to a generic Error`() { + val state = + mapOutcomeToResolveState(NamecoinResolveOutcome.InvalidIdentifier("not_a_name")) + require(state is NamecoinResolveState.Error) + assertEquals("Invalid Namecoin identifier", state.message) + } + + @Test + fun `timeout maps to a timeout Error`() { + val state = mapOutcomeToResolveState(NamecoinResolveOutcome.Timeout) + require(state is NamecoinResolveState.Error) + assertTrue(state.message.contains("timed out")) + } + + @Test(expected = IllegalStateException::class) + fun `success outcome must be handled by callers, not mapOutcomeToResolveState`() { + // mapOutcomeToResolveState is documented as failure-only; callers must + // route NamecoinResolveOutcome.Success through LocalCache themselves. + mapOutcomeToResolveState( + NamecoinResolveOutcome.Success( + NamecoinNostrResult( + pubkey = "deadbeef".repeat(8), + relays = emptyList(), + namecoinName = "d/testls", + localPart = "_", + ), + ), + ) + } +} From 1c5230cfc5f7fe9d89e240af26fcab288c1585b3 Mon Sep 17 00:00:00 2001 From: m Date: Tue, 19 May 2026 06:33:47 +1000 Subject: [PATCH 16/21] feat(search): inline Namecoin resolution indicator in global search bar Reuses the NamecoinResolutionRow composable already shipping for the onchain-zap recipient field, promoting it from ui/screen/loggedIn/wallet/ to a generic ui/components/namecoin/ location so it can be mounted anywhere a .bit-shaped search input may race the local-cache prefix search. In the global search bar, typing a bare ".bit" host (e.g. "testls.bit") used to surface a cached sibling profile like "m@testls.bit" first (LocalCache.findUsersStartingWith hits the prefix) and only several seconds later be corrected by the slower on-chain ElectrumX resolution from SearchBarViewModel.directNip05Resolver. No in-flight indicator and no feedback on hard failures (timeout, malformed record, etc.). Changes: - git-rename NamecoinResolutionRow.kt and its test from ui/screen/loggedIn/wallet/ to ui/components/namecoin/, updating the package declaration only. - Add an optional `modifier: Modifier = Modifier` parameter to the composable (standard Compose convention) and wrap the spinner / result / error rows in a Column taking the caller-provided modifier. No visual change in OnchainZapSendDialog. - Update OnchainZapSendDialog import to the new package location. - Mount NamecoinResolutionRow in SearchScreen.SearchBar between SearchTextField and SearchFilterRow, with horizontal padding to match the rest of the bar. onUserResolved navigates to the user and clears the field, matching the bech32 auto-resolve path in SearchBarViewModel.directRouteResolver. State is held in the shared commons.NamecoinResolveState (no new state class introduced) and diagnostic wording comes from the existing mapOutcomeToResolveState helper, so every Namecoin surface continues to produce the same message for the same outcome. --- .../namecoin}/NamecoinResolutionRow.kt | 29 ++++++++++++------- .../ui/screen/loggedIn/search/SearchScreen.kt | 19 ++++++++++++ .../loggedIn/wallet/OnchainZapSendDialog.kt | 1 + .../namecoin}/NamecoinResolutionRowTest.kt | 2 +- 4 files changed, 39 insertions(+), 12 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{screen/loggedIn/wallet => components/namecoin}/NamecoinResolutionRow.kt (92%) rename amethyst/src/test/java/com/vitorpamplona/amethyst/ui/{screen/loggedIn/wallet => components/namecoin}/NamecoinResolutionRowTest.kt (99%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRow.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRow.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRow.kt index 8709b21734..e3bca2104d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRow.kt @@ -18,7 +18,7 @@ * 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.amethyst.ui.screen.loggedIn.wallet +package com.vitorpamplona.amethyst.ui.components.namecoin import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -111,15 +111,16 @@ fun looksLikeNamecoinIdentifier(raw: String): Boolean { } /** - * Inline Namecoin resolution indicator + result row, sandwiched between - * the recipient text field and the local-cache suggestion dropdown in - * [OnchainZapSendDialog]. + * Inline Namecoin resolution indicator + result row. Designed to be + * mounted alongside any text input whose local-cache prefix search can + * race ahead of an on-chain `.bit` lookup (the onchain-zap recipient + * field and the global search bar both have this race). * * Behaviour: * - Renders nothing when [searchInput] is not a `.bit` identifier. * - Shows a small spinner row ("Resolving on Namecoin…") while the * ElectrumX lookup is in flight (after a 300 ms debounce to match - * the dropdown's own debounce). + * typical input-field debounce intervals). * - On success, shows the resolved user as a tappable row with a * `MaterialSymbols.Link` badge labelled "Namecoin"; tapping calls * [onUserResolved]. @@ -132,12 +133,16 @@ fun looksLikeNamecoinIdentifier(raw: String): Boolean { * The composable is intentionally self-contained: it owns its own * [LaunchedEffect] keyed on [searchInput], so it cancels in-flight * lookups whenever the user keeps typing. + * + * @param modifier applied to the outer `Column` so callers can position + * or pad the row (e.g. the search bar pads horizontally). */ @Composable fun NamecoinResolutionRow( searchInput: String, accountViewModel: AccountViewModel, onUserResolved: (User) -> Unit, + modifier: Modifier = Modifier, ) { val trimmed = remember(searchInput) { searchInput.trim().removePrefix("@") } if (!looksLikeNamecoinIdentifier(trimmed)) return @@ -166,12 +171,14 @@ fun NamecoinResolutionRow( } } - Spacer(Modifier.size(8.dp)) - when (val s = state) { - null, NamecoinResolveState.Loading -> ResolvingChip(trimmed) - is NamecoinResolveState.Resolved -> ResolvedRow(trimmed, s, accountViewModel, onUserResolved) - NamecoinResolveState.NotFound -> FailedRow("No record for $trimmed on Namecoin.") - is NamecoinResolveState.Error -> FailedRow(s.message) + Column(modifier = modifier) { + Spacer(Modifier.size(8.dp)) + when (val s = state) { + null, NamecoinResolveState.Loading -> ResolvingChip(trimmed) + is NamecoinResolveState.Resolved -> ResolvedRow(trimmed, s, accountViewModel, onUserResolved) + NamecoinResolveState.NotFound -> FailedRow("No record for $trimmed on Namecoin.") + is NamecoinResolveState.Error -> FailedRow(s.message) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 0ffcd44282..61cc4d5f16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchSource import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription +import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding @@ -194,6 +195,24 @@ private fun SearchBar( Column(modifier = Modifier.statusBarsPadding()) { SearchTextField(searchBarViewModel, Modifier) + // Inline Namecoin lookup feedback for the global search field. + // Mirrors the wiring in OnchainZapSendDialog: the local prefix + // search can race ahead of the on-chain resolution and show a + // cached sibling profile (e.g. "m@testls.bit") before the bare + // ".bit" host resolves to its `_@host` profile. Surfaces the + // in-flight state, the eventual on-chain match, and any failure + // explicitly. Tapping the resolved row navigates to the user and + // clears the search field, matching the existing bech32 auto- + // resolve behaviour in `SearchBarViewModel.directRouteResolver`. + NamecoinResolutionRow( + searchInput = searchBarViewModel.searchValue, + accountViewModel = accountViewModel, + onUserResolved = { user -> + nav.nav(routeFor(user)) + searchBarViewModel.clear() + }, + modifier = Modifier.padding(horizontal = 10.dp), + ) SearchFilterRow(searchBarViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index 8199baf54d..f0738c3956 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRowTest.kt similarity index 99% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRowTest.kt rename to amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRowTest.kt index 2554afbdaf..af91222256 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/NamecoinResolutionRowTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/namecoin/NamecoinResolutionRowTest.kt @@ -18,7 +18,7 @@ * 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.amethyst.ui.screen.loggedIn.wallet +package com.vitorpamplona.amethyst.ui.components.namecoin import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult From 669199ab7e91e3a1d806976943f26b6917cd3017 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:21:02 +0000 Subject: [PATCH 17/21] refactor(quartz): use LargeCache for RelayAuthenticator authStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2946 fixed the ClassCastException with a bespoke AtomicReference + CAS copy-on-write helper. Quartz already has a concurrent-map abstraction for exactly this purpose — LargeCache — with platform-tuned actuals (ConcurrentSkipListMap on jvmAndroid, CacheMap on Apple, custom on Linux). Swap to it. Removes the bespoke putAuthStatus/removeAuthStatus helpers, the ExperimentalAtomicApi opt-in, and the AtomicReference imports. The RelayAuthenticatorConcurrencyTest from #2946 still passes against the new implementation. --- .../relay/client/auth/RelayAuthenticator.kt | 40 +++++-------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index 846469de0b..848b3f810f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -31,13 +31,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi interface IAuthStatus { fun hasFinishedAuthentication(relay: NormalizedRelayUrl): Boolean @@ -47,36 +46,15 @@ object EmptyIAuthStatus : IAuthStatus { override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = true } -@OptIn(ExperimentalAtomicApi::class) class RelayAuthenticator( val client: INostrClient, val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), val signWithAllLoggedInUsers: suspend (EventTemplate) -> List, ) : IAuthStatus { // Connection callbacks fire on the per-relay OkHttp dispatcher thread, so - // this state is mutated concurrently — copy-on-write under AtomicReference. - private val authStatus: AtomicReference> = - AtomicReference(emptyMap()) - - private fun putAuthStatus( - relay: NormalizedRelayUrl, - status: RelayAuthStatus, - ) { - while (true) { - val current = authStatus.load() - val next = current + (relay to status) - if (authStatus.compareAndSet(current, next)) return - } - } - - private fun removeAuthStatus(relay: NormalizedRelayUrl) { - while (true) { - val current = authStatus.load() - if (relay !in current) return - val next = current - relay - if (authStatus.compareAndSet(current, next)) return - } - } + // this state is mutated concurrently — LargeCache wraps a platform-tuned + // concurrent map (ConcurrentSkipListMap on jvmAndroid, CacheMap on Apple). + private val authStatus = LargeCache() private val clientListener = object : RelayConnectionListener { @@ -92,11 +70,11 @@ class RelayAuthenticator( } override fun onConnecting(relay: IRelayClient) { - putAuthStatus(relay.url, RelayAuthStatus()) + authStatus.put(relay.url, RelayAuthStatus()) } override fun onDisconnected(relay: IRelayClient) { - removeAuthStatus(relay.url) + authStatus.remove(relay.url) } } @@ -108,7 +86,7 @@ class RelayAuthenticator( val ev = RelayAuthEvent.build(relay.url, msg.challenge) signWithAllLoggedInUsers(ev).forEach { authEvent -> // only send replies to new challenges to avoid infinite loop: - if (authStatus.load()[relay.url]?.saveAuthSubmission(authEvent) == true) { + if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) { relay.sendIfConnected(AuthCmd(authEvent)) } } @@ -120,12 +98,12 @@ class RelayAuthenticator( msg: OkMessage, ) { // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. - if (authStatus.load()[relay.url]?.checkAuthResults(msg.eventId, msg.success) == true) { + if (authStatus.get(relay.url)?.checkAuthResults(msg.eventId, msg.success) == true) { client.syncFilters(relay) } } - override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = authStatus.load()[relay]?.hasFinishedAllAuths() != false + override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = authStatus.get(relay)?.hasFinishedAllAuths() != false init { Log.d("RelayAuthenticator", "Init, Subscribe") From 48d9e80e2059b5e33d4aa8efeb623a1877c99667 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:34:32 +0000 Subject: [PATCH 18/21] refactor: address self-audit on notification settings - Promote the duplicate `SwitchTile` from SecurityFiltersScreen and NotificationSettingsScreen into a shared `SettingsSwitchTile` in SettingsSectionCard so all settings switches share one implementation. - Render BatteryOptimizationBanner outside the in-app section card instead of nesting a Card inside a Card and splitting the section's divider away from the row it explains. - Refresh the battery-optimization exemption on LifecycleResumeEffect so the banner disappears after the user returns from the system settings page; drop the racy post-button re-read. - Demote `HasPushNotificationProvider` to a plain `hasPushNotificationProvider` since it returns a per-flavor constant and reads no Compose state. --- .../components/SelectNotificationProvider.kt | 3 +- .../settings/NotificationSettingsScreen.kt | 142 +++++++----------- .../settings/SecurityFiltersScreen.kt | 27 +--- .../loggedIn/settings/SettingsSectionCard.kt | 20 +++ .../components/SelectNotificationProvider.kt | 3 +- 5 files changed, 81 insertions(+), 114 deletions(-) diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index ee8b2139af..e61c08b852 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -207,8 +207,7 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList, Immuta ) } -@Composable -fun HasPushNotificationProvider(): Boolean = true +fun hasPushNotificationProvider(): Boolean = true @Composable fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt index 8b1c463713..5c2450d9c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -32,7 +31,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -44,13 +42,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper -import com.vitorpamplona.amethyst.ui.components.HasPushNotificationProvider import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile +import com.vitorpamplona.amethyst.ui.components.hasPushNotificationProvider import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -75,109 +73,81 @@ fun NotificationSettingsScreen( .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { - if (HasPushNotificationProvider()) { + if (hasPushNotificationProvider()) { SettingsSection(R.string.notification_settings_section_push) { PushNotificationProviderTile(accountViewModel.settings.uiSettingsFlow) } } + val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled + .collectAsStateWithLifecycle() + SettingsSection(R.string.notification_settings_section_in_app) { - AlwaysOnServiceTile(accountViewModel) + SettingsSwitchTile( + icon = MaterialSymbols.Notifications, + title = R.string.always_on_notif_setting_title, + description = R.string.always_on_notif_setting_description, + checked = alwaysOn, + onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() }, + ) SettingsDivider() - SplitByFollowsTile(accountViewModel) + SettingsSwitchTile( + icon = MaterialSymbols.Forum, + title = R.string.split_notifications_setting_title, + description = R.string.split_notifications_setting_description, + checked = splitByFollows, + onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() }, + ) + } + + if (alwaysOn) { + BatteryOptimizationBanner() } } } } -@Composable -private fun AlwaysOnServiceTile(accountViewModel: AccountViewModel) { - val enabled by accountViewModel.account.settings.alwaysOnNotificationService - .collectAsStateWithLifecycle() - - SwitchTile( - icon = MaterialSymbols.Notifications, - title = R.string.always_on_notif_setting_title, - description = R.string.always_on_notif_setting_description, - checked = enabled, - onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() }, - ) - - if (enabled) { - BatteryOptimizationBanner() - } -} - -@Composable -private fun SplitByFollowsTile(accountViewModel: AccountViewModel) { - val enabled by accountViewModel.account.settings.splitNotificationsEnabled - .collectAsStateWithLifecycle() - - SwitchTile( - icon = MaterialSymbols.Forum, - title = R.string.split_notifications_setting_title, - description = R.string.split_notifications_setting_description, - checked = enabled, - onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() }, - ) -} - -@Composable -private fun SwitchTile( - icon: MaterialSymbol, - @StringRes title: Int, - @StringRes description: Int, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - SettingsControlRow( - icon = icon, - title = stringRes(title), - description = stringRes(description), - onClick = { onCheckedChange(!checked) }, - ) { - Switch(checked = checked, onCheckedChange = onCheckedChange) - } -} - @Composable private fun BatteryOptimizationBanner() { val context = LocalContext.current var isExempt by remember { mutableStateOf(BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)) } + LifecycleResumeEffect(Unit) { + isExempt = BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context) + onPauseOrDispose {} + } - if (!isExempt) { - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), + if (isExempt) return + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + Text( + text = stringRes(R.string.battery_optimization_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringRes(R.string.battery_optimization_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Button( + onClick = { BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) }, ) { - Text( - text = stringRes(R.string.battery_optimization_title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - text = stringRes(R.string.battery_optimization_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Button( - onClick = { - BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) - isExempt = BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context) - }, - ) { - Text(stringRes(R.string.battery_optimization_fix_now)) - } + Text(stringRes(R.string.battery_optimization_fix_now)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 11faac4803..d3f9905105 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -31,7 +30,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -40,7 +38,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.WarningType import com.vitorpamplona.amethyst.model.parseWarningType @@ -122,7 +119,7 @@ private fun FilterSpamTile(accountViewModel: AccountViewModel) { .filterSpamFromStrangers .collectAsStateWithLifecycle() - SwitchTile( + SettingsSwitchTile( icon = MaterialSymbols.FilterAlt, title = R.string.filter_spam_from_strangers_title, description = R.string.filter_spam_from_strangers_explainer, @@ -136,7 +133,7 @@ private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) { val hideViolations by accountViewModel.account.settings.hideCommunityRulesViolations .collectAsStateWithLifecycle() - SwitchTile( + SettingsSwitchTile( icon = MaterialSymbols.Shield, title = R.string.hide_community_rules_violations_title, description = R.string.hide_community_rules_violations_explainer, @@ -151,7 +148,7 @@ private fun WarnReportsTile(accountViewModel: AccountViewModel) { val warnReports by security.warnAboutPostsWithReports.collectAsStateWithLifecycle() val threshold by security.reportWarningThreshold.collectAsStateWithLifecycle() - SwitchTile( + SettingsSwitchTile( icon = MaterialSymbols.Report, title = R.string.warn_when_posts_have_reports_from_your_follows_title, description = R.string.warn_when_posts_have_reports_from_your_follows_explainer, @@ -194,24 +191,6 @@ private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { } } -@Composable -private fun SwitchTile( - icon: MaterialSymbol, - @StringRes title: Int, - @StringRes description: Int, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - SettingsControlRow( - icon = icon, - title = stringRes(title), - description = stringRes(description), - onClick = { onCheckedChange(!checked) }, - ) { - Switch(checked = checked, onCheckedChange = onCheckedChange) - } -} - @Composable private fun BlockedContentSection( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt index d190d18803..a188839456 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -248,6 +249,25 @@ internal fun SettingsControlRow( } } +/** A [SettingsControlRow] whose trailing control is a [Switch]; tapping anywhere toggles. */ +@Composable +internal fun SettingsSwitchTile( + icon: MaterialSymbol, + @StringRes title: Int, + @StringRes description: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + SettingsControlRow( + icon = icon, + title = stringRes(title), + description = stringRes(description), + onClick = { onCheckedChange(!checked) }, + ) { + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + /** * Sub-row variant of [SettingsControlRow]: indented in place of a leading icon, * used for controls hierarchically grouped under the row above (e.g. a threshold diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 2f086def3f..dc32830cc4 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -54,5 +54,4 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) { @Composable fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {} -@Composable -fun HasPushNotificationProvider(): Boolean = false +fun hasPushNotificationProvider(): Boolean = false From 6be26e14127c16ad8e9135750ab17207bbbb156c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:35:38 +0000 Subject: [PATCH 19/21] fix(media): route media-upload signing through launchSigner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous narrow try/catch fix. The earlier patch silently swallowed SignerExceptions in NewMediaModel / EditPostViewModel — no toast, no error dialog — so the user would tap Post, see the dialog close, and never learn that the signer prompt timed out or was rejected. It also bypassed the project's standard signer-error pipeline. This version routes the sign+publish phase through AccountViewModel.launchSigner, which is the same path every other signing entry point uses: - ReadOnly / SignerNotFound / UnauthorizedDecryption / IllegalState → toastManager surfaces a localized alert. - TimedOut / ManuallyUnauthorized / etc. → logged silently (no crash), matching the rest of the app's behavior when a user dismisses the external signer. NewMediaModel.upload now takes an AccountViewModel parameter; the three inner viewModelScope.launch(Dispatchers.IO) blocks become accountViewModel.launchSigner { ... }. The joinAll wait is preserved (launchSigner returns Job). EditPostViewModel.uploadUnsafe routes its single launch the same way and wraps the body in try/finally so mediaUploadTracker.finishUpload() still runs if a signer throws mid-flow. launchSigner is changed from Unit to Job (= viewModelScope.launch ...) so callers can join — non-breaking for the ~190 existing callsites that ignore the return value. --- .../amethyst/ui/actions/EditPostViewModel.kt | 131 +++++++++--------- .../amethyst/ui/actions/NewMediaModel.kt | 19 ++- .../amethyst/ui/actions/NewMediaView.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 3 +- 4 files changed, 79 insertions(+), 76 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index a40e19f2f1..583dac2920 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.currentWord import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor @@ -64,8 +63,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Stable open class EditPostViewModel : ViewModel() { @@ -186,81 +183,83 @@ open class EditPostViewModel : ViewModel() { context: Context, stripMetadata: Boolean = true, ) { - viewModelScope.launch(Dispatchers.IO) { + accountViewModel.launchSigner { val myAccount = account - val myMultiOrchestrator = multiOrchestrator ?: return@launch + val myMultiOrchestrator = multiOrchestrator ?: return@launchSigner mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) - val results = - myMultiOrchestrator.upload( - alt, - if (sensitiveContent) "" else null, - MediaCompressor.intToCompressorQuality(mediaQuality), - server, - myAccount, - context, - useH265Codec, - stripMetadata, - onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, - ) + try { + val results = + myMultiOrchestrator.upload( + alt, + if (sensitiveContent) "" else null, + MediaCompressor.intToCompressorQuality(mediaQuality), + server, + myAccount, + context, + useH265Codec, + stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) - if (results.allGood) { - val urls = - results.successful.mapNotNull { state -> - if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - val nip95 = - myAccount.createNip95( - byteArray = state.result.bytes, - headerInfo = state.result.fileHeader, - alt = alt, - contentWarningReason = if (sensitiveContent) "" else null, - ) - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } + if (results.allGood) { + val urls = + results.successful.mapNotNull { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { + val nip95 = + myAccount.createNip95( + byteArray = state.result.bytes, + headerInfo = state.result.fileHeader, + alt = alt, + contentWarningReason = if (sensitiveContent) "" else null, + ) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } - note?.let { - "nostr:" + it.toNEvent() + note?.let { + "nostr:" + it.toNEvent() + } + } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val iMeta = + IMetaTagBuilder(state.result.url) + .apply { + hash(state.result.fileHeader.hash) + size(state.result.fileHeader.size) + state.result.fileHeader.mimeType + ?.let { mimeType(it) } + state.result.fileHeader.dim + ?.let { dims(it) } + state.result.fileHeader.blurHash + ?.let { blurhash(it.blurhash) } + state.result.fileHeader.thumbHash + ?.let { thumbhash(it.thumbhash) } + state.result.magnet?.let { magnet(it) } + state.result.uploadedHash?.let { originalHash(it) } + alt?.let { alt(it) } + if (sensitiveContent) sensitiveContent("") + }.build() + + iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta + + state.result.url + } else { + null } - } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - val iMeta = - IMetaTagBuilder(state.result.url) - .apply { - hash(state.result.fileHeader.hash) - size(state.result.fileHeader.size) - state.result.fileHeader.mimeType - ?.let { mimeType(it) } - state.result.fileHeader.dim - ?.let { dims(it) } - state.result.fileHeader.blurHash - ?.let { blurhash(it.blurhash) } - state.result.fileHeader.thumbHash - ?.let { thumbhash(it.thumbhash) } - state.result.magnet?.let { magnet(it) } - state.result.uploadedHash?.let { originalHash(it) } - alt?.let { alt(it) } - if (sensitiveContent) sensitiveContent("") - }.build() - - iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta - - state.result.url - } else { - null } - } - message = message.insertUrlAtCursor(urls.joinToString(" ")) - urlPreview = findUrlInMessage() + message = message.insertUrlAtCursor(urls.joinToString(" ")) + urlPreview = findUrlInMessage() - this@EditPostViewModel.multiOrchestrator = null - } else { - val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + this@EditPostViewModel.multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() - onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + } finally { + mediaUploadTracker.finishUpload() } - - mediaUploadTracker.finishUpload() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 563cb0ce69..91ea5cfe64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import kotlinx.collections.immutable.ImmutableList @@ -90,10 +91,11 @@ open class NewMediaModel : ViewModel() { fun upload( context: Context, + accountViewModel: AccountViewModel, onSucess: () -> Unit, onError: (String, String) -> Unit, ) = try { - uploadUnsafe(context, onSucess, onError) + uploadUnsafe(context, accountViewModel, onSucess, onError) } catch (e: SignerExceptions.ReadOnlyException) { onError( stringRes(context, R.string.read_only_user), @@ -103,6 +105,7 @@ open class NewMediaModel : ViewModel() { fun uploadUnsafe( context: Context, + accountViewModel: AccountViewModel, onSucess: () -> Unit, onError: (String, String) -> Unit, ) { @@ -155,10 +158,13 @@ open class NewMediaModel : ViewModel() { } }.toMap() + // Sign + publish via launchSigner so SignerExceptions surface + // through the standard toastManager pipeline (and timed-out / + // rejected prompts get logged instead of crashing the process). val nip95jobs = nip95s.map { // upload each file as an individual nip95 event. - viewModelScope.launch(Dispatchers.IO) { + accountViewModel.launchSigner { val nip95 = myAccount.createNip95(it.bytes, headerInfo = it.fileHeader, caption, if (sensitiveContent) "" else null) myAccount.consumeAndSendNip95(nip95.first, nip95.second) } @@ -166,9 +172,8 @@ open class NewMediaModel : ViewModel() { val videoJobs = videosAndOthers.map { - // upload each file as an individual nip95 event. - viewModelScope.launch(Dispatchers.IO) { - account?.sendHeader( + accountViewModel.launchSigner { + myAccount.sendHeader( url = it.url, magnetUri = it.magnet, headerInfo = it.fileHeader, @@ -182,8 +187,8 @@ open class NewMediaModel : ViewModel() { val imageJobs = if (imageUrls.isNotEmpty()) { listOf( - viewModelScope.launch(Dispatchers.IO) { - account?.sendAllAsOnePictureEvent( + accountViewModel.launchSigner { + myAccount.sendAllAsOnePictureEvent( urlHeaderInfo = imageUrls, caption = caption, contentWarningReason = if (sensitiveContent) "" else null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 63a21a0212..62d165677a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -110,7 +110,7 @@ fun NewMediaView( onClose() }, onPost = { - postViewModel.upload(context, onClose, accountViewModel.toastManager::toast) + postViewModel.upload(context, accountViewModel, onClose, accountViewModel.toastManager::toast) postViewModel.selectedServer?.let { account.settings.changeDefaultFileServer(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index f650d11a6c..40b2e8ef87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1061,7 +1061,7 @@ class AccountViewModel( } } - inline fun launchSigner(crossinline action: suspend () -> Unit) { + inline fun launchSigner(crossinline action: suspend () -> Unit) = viewModelScope.launch(Dispatchers.IO) { try { action() @@ -1100,7 +1100,6 @@ class AccountViewModel( ) } } - } fun approveCommunityPost( post: Note, From 4d88444a5920211d2a67bd28ae4a9d5006bdfc9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:49:09 +0000 Subject: [PATCH 20/21] feat(notifications): split delivery vs display, add Categories section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganize Notification settings into three sections that reflect what each control actually does: - Delivery: how notifications reach the device — push provider (fdroid) and the always-on relay service live together here. - In-app display: how the notifications screen renders incoming activity — currently the Split-by-Follows toggle. - Categories: one row per user-facing Android NotificationChannel (DMs, Mentions, Replies, Reactions, Zaps, Chess, Scheduled posts, Calls), showing the current importance (On / Silent / Off) and opening the system per-channel settings page on tap. Foreground-service channels are intentionally omitted — disabling them breaks the service contract. The screen ensures every listed channel exists on first open and re-reads importance via LifecycleResumeEffect so the badge reflects changes made in system settings. API surface bumped for the channel registry: - CallNotifier.CALL_CHANNEL_ID is now public. - ScheduledPostNotifier.ensureChannel is now public. --- .../service/call/notification/CallNotifier.kt | 2 +- .../notifications/NotificationChannels.kt | 166 +++++++++++++++++ .../scheduledposts/ScheduledPostNotifier.kt | 2 +- .../settings/NotificationSettingsScreen.kt | 176 ++++++++++++++---- amethyst/src/main/res/values/strings.xml | 10 +- 5 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt index 529f11ef78..789f6ff535 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt @@ -60,7 +60,7 @@ import kotlinx.coroutines.withContext */ object CallNotifier { private var callChannel: NotificationChannel? = null - private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL" + const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL" private const val CALL_NOTIFICATION_ID = 0x50000 fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt new file mode 100644 index 0000000000..c538def017 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 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.amethyst.service.notifications + +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.core.app.NotificationManagerCompat +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.call.notification.CallNotifier +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.Log + +/** + * Registry of user-facing notification channels and helpers to read their + * current importance / open the system settings page for them. + * + * Android (post-Oreo) owns channel state — the app cannot toggle channel + * importance directly. The Notifications settings screen surfaces the + * channels here and routes the user to the system per-channel page. + * + * Foreground-service channels (relay-connection, nests audio) are + * intentionally omitted: they're functional indicators, not content + * notifications, and disabling them breaks the foreground service contract. + */ +object NotificationChannels { + private const val TAG = "NotificationChannels" + + enum class ChannelStatus { ON, SILENT, OFF } + + /** + * A single content-bearing notification channel exposed in the settings UI. + * [ensure] creates the channel if missing — needed so the system per-channel + * settings page has something to open even before the first notification fires. + */ + data class Entry( + val nameRes: Int, + val icon: MaterialSymbol, + val channelId: (Context) -> String, + val ensure: (Context) -> Unit, + ) + + val contentChannels: List = + listOf( + Entry( + nameRes = R.string.app_notification_dms_channel_name, + icon = MaterialSymbols.Mail, + channelId = { stringRes(it, R.string.app_notification_dms_channel_id) }, + ensure = { NotificationUtils.getOrCreateDMChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_mentions_channel_name, + icon = MaterialSymbols.AlternateEmail, + channelId = { stringRes(it, R.string.app_notification_mentions_channel_id) }, + ensure = { NotificationUtils.getOrCreateMentionChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_replies_channel_name, + icon = MaterialSymbols.Chat, + channelId = { stringRes(it, R.string.app_notification_replies_channel_id) }, + ensure = { NotificationUtils.getOrCreateReplyChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_reactions_channel_name, + icon = MaterialSymbols.Favorite, + channelId = { stringRes(it, R.string.app_notification_reactions_channel_id) }, + ensure = { NotificationUtils.getOrCreateReactionChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_zaps_channel_name, + icon = MaterialSymbols.Bolt, + channelId = { stringRes(it, R.string.app_notification_zaps_channel_id) }, + ensure = { NotificationUtils.getOrCreateZapChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_chess_channel_name, + icon = MaterialSymbols.ChessKnight, + channelId = { stringRes(it, R.string.app_notification_chess_channel_id) }, + ensure = { NotificationUtils.getOrCreateChessChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_scheduled_posts_channel_name, + icon = MaterialSymbols.Schedule, + channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) }, + ensure = { ScheduledPostNotifier.ensureChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_calls_channel_name, + icon = MaterialSymbols.Call, + channelId = { CallNotifier.CALL_CHANNEL_ID }, + ensure = { CallNotifier.getOrCreateCallChannel(it) }, + ), + ) + + fun statusOf( + context: Context, + channelId: String, + ): ChannelStatus { + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return ChannelStatus.OFF + val nm = context.getSystemService(NotificationManager::class.java) ?: return ChannelStatus.OFF + val channel = nm.getNotificationChannel(channelId) ?: return ChannelStatus.ON + return when (channel.importance) { + NotificationManager.IMPORTANCE_NONE -> ChannelStatus.OFF + NotificationManager.IMPORTANCE_MIN, NotificationManager.IMPORTANCE_LOW -> ChannelStatus.SILENT + else -> ChannelStatus.ON + } + } + + /** + * Opens the system per-channel notification settings page. Falls back to + * the app-level notification settings if the per-channel intent isn't + * supported (e.g. the channel was never created, or on stripped-down ROMs). + */ + fun openChannelSettings( + context: Context, + channelId: String, + ) { + try { + val intent = + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, channelId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (e: Exception) { + Log.w(TAG, "Per-channel intent failed, falling back to app notification settings", e) + openAppNotificationSettings(context) + } + } + + fun openAppNotificationSettings(context: Context) { + try { + val intent = + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (e: Exception) { + Log.e(TAG, "Failed to open app notification settings", e) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt index 6ac5b47415..d545ae7760 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt @@ -122,7 +122,7 @@ object ScheduledPostNotifier { } } - private fun ensureChannel(context: Context) { + fun ensureChannel(context: Context) { if (channel != null) return channel = NotificationChannel( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt index 5c2450d9c7..14a39ad7b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt @@ -20,11 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -37,7 +40,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -47,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper +import com.vitorpamplona.amethyst.service.notifications.NotificationChannels import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile import com.vitorpamplona.amethyst.ui.components.hasPushNotificationProvider import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav @@ -73,42 +80,147 @@ fun NotificationSettingsScreen( .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { - if (hasPushNotificationProvider()) { - SettingsSection(R.string.notification_settings_section_push) { - PushNotificationProviderTile(accountViewModel.settings.uiSettingsFlow) - } - } - - val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService - .collectAsStateWithLifecycle() - val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled - .collectAsStateWithLifecycle() - - SettingsSection(R.string.notification_settings_section_in_app) { - SettingsSwitchTile( - icon = MaterialSymbols.Notifications, - title = R.string.always_on_notif_setting_title, - description = R.string.always_on_notif_setting_description, - checked = alwaysOn, - onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() }, - ) - SettingsDivider() - SettingsSwitchTile( - icon = MaterialSymbols.Forum, - title = R.string.split_notifications_setting_title, - description = R.string.split_notifications_setting_description, - checked = splitByFollows, - onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() }, - ) - } - - if (alwaysOn) { - BatteryOptimizationBanner() - } + DeliverySection(accountViewModel) + DisplaySection(accountViewModel) + CategoriesSection() } } } +@Composable +private fun DeliverySection(accountViewModel: AccountViewModel) { + val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + + SettingsSection(R.string.notification_settings_section_delivery) { + if (hasPushNotificationProvider()) { + PushNotificationProviderTile(accountViewModel.settings.uiSettingsFlow) + SettingsDivider() + } + SettingsSwitchTile( + icon = MaterialSymbols.Notifications, + title = R.string.always_on_notif_setting_title, + description = R.string.always_on_notif_setting_description, + checked = alwaysOn, + onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() }, + ) + } + + if (alwaysOn) { + BatteryOptimizationBanner() + } +} + +@Composable +private fun DisplaySection(accountViewModel: AccountViewModel) { + val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled + .collectAsStateWithLifecycle() + + SettingsSection(R.string.notification_settings_section_display) { + SettingsSwitchTile( + icon = MaterialSymbols.Forum, + title = R.string.split_notifications_setting_title, + description = R.string.split_notifications_setting_description, + checked = splitByFollows, + onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() }, + ) + } +} + +@Composable +private fun CategoriesSection() { + val context = LocalContext.current + val entries = NotificationChannels.contentChannels + + // Ensure every channel exists so the system per-channel page has something + // to open even on a fresh install where the user hasn't received that kind + // of notification yet. + remember(entries) { + entries.forEach { runCatching { it.ensure(context) } } + } + + // Re-read importance on resume so toggling sound/importance in the system + // page reflects back when the user returns. + var refreshKey by remember { mutableStateOf(0) } + LifecycleResumeEffect(Unit) { + refreshKey++ + onPauseOrDispose {} + } + + SettingsSection(R.string.notification_settings_section_categories) { + entries.forEachIndexed { index, entry -> + if (index > 0) SettingsDivider() + val channelId = remember(entry) { entry.channelId(context) } + val status = + remember(refreshKey, channelId) { + NotificationChannels.statusOf(context, channelId) + } + SettingsItem( + title = entry.nameRes, + icon = entry.icon, + trailing = { ChannelStatusBadge(status) }, + onClick = { NotificationChannels.openChannelSettings(context, channelId) }, + ) + } + } + + Text( + text = stringRes(R.string.notification_settings_categories_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) +} + +@Composable +private fun ChannelStatusBadge(status: NotificationChannels.ChannelStatus) { + val (label, container, content) = + when (status) { + NotificationChannels.ChannelStatus.ON -> + Triple( + R.string.notification_channel_status_on, + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.onSecondaryContainer, + ) + NotificationChannels.ChannelStatus.SILENT -> + Triple( + R.string.notification_channel_status_silent, + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.colorScheme.onSurfaceVariant, + ) + NotificationChannels.ChannelStatus.OFF -> + Triple( + R.string.notification_channel_status_off, + MaterialTheme.colorScheme.errorContainer, + MaterialTheme.colorScheme.onErrorContainer, + ) + } + + StatusChip(label = stringRes(label), containerColor = container, contentColor = content) +} + +@Composable +private fun StatusChip( + label: String, + containerColor: Color, + contentColor: Color, +) { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(containerColor) + .padding(horizontal = 10.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = contentColor, + ) + } +} + @Composable private fun BatteryOptimizationBanner() { val context = LocalContext.current diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8e7d495a02..3311e94ea4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1675,8 +1675,14 @@ No reactions setup Notifications - In-app notifications - Push notifications + Delivery + In-app display + Categories + Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there. + Open Android notification settings + On + Silent + Off Select a UnifiedPush App Push provider From b2b48955701da4a86131153f070cee610f54c288 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:53:32 +0000 Subject: [PATCH 21/21] refactor(notifications): cleaner Compose patterns in Categories section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `remember { side effect }` with lazy `entry.ensure(context)` in the row's onClick; the system per-channel page only needs the channel to exist at open time, not before, and the ensure call is idempotent. - Replace the `refreshKey++` invalidation trick with a real `Map` state holder updated inside `LifecycleResumeEffect`; downstream reads are direct map lookups. - Replace `Triple`-with-destructuring in `ChannelStatusBadge` with three direct `when` branches calling `StatusChip` — no tuples, no temporaries. - Drop the unused `notification_settings_open_system` string. --- .../settings/NotificationSettingsScreen.kt | 78 ++++++++++--------- amethyst/src/main/res/values/strings.xml | 1 - 2 files changed, 40 insertions(+), 39 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt index 14a39ad7b0..b2914644d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt @@ -132,18 +132,18 @@ private fun CategoriesSection() { val context = LocalContext.current val entries = NotificationChannels.contentChannels - // Ensure every channel exists so the system per-channel page has something - // to open even on a fresh install where the user hasn't received that kind - // of notification yet. - remember(entries) { - entries.forEach { runCatching { it.ensure(context) } } + // Read each channel's importance after every resume so toggling + // sound/importance in the system page reflects back here. The map IS + // the state — no key-bump trick needed. + var statuses by remember { + mutableStateOf>(emptyMap()) } - - // Re-read importance on resume so toggling sound/importance in the system - // page reflects back when the user returns. - var refreshKey by remember { mutableStateOf(0) } LifecycleResumeEffect(Unit) { - refreshKey++ + statuses = + entries.associate { + val id = it.channelId(context) + id to NotificationChannels.statusOf(context, id) + } onPauseOrDispose {} } @@ -151,15 +151,20 @@ private fun CategoriesSection() { entries.forEachIndexed { index, entry -> if (index > 0) SettingsDivider() val channelId = remember(entry) { entry.channelId(context) } - val status = - remember(refreshKey, channelId) { - NotificationChannels.statusOf(context, channelId) - } + // Default to ON for channels not yet created — matches Android's + // own default importance, so the badge isn't misleading before the + // user has interacted with the channel. + val status = statuses[channelId] ?: NotificationChannels.ChannelStatus.ON SettingsItem( title = entry.nameRes, icon = entry.icon, trailing = { ChannelStatusBadge(status) }, - onClick = { NotificationChannels.openChannelSettings(context, channelId) }, + onClick = { + // Lazy-create the channel right before opening so the system + // per-channel page has something to display; idempotent. + entry.ensure(context) + NotificationChannels.openChannelSettings(context, channelId) + }, ) } } @@ -174,29 +179,26 @@ private fun CategoriesSection() { @Composable private fun ChannelStatusBadge(status: NotificationChannels.ChannelStatus) { - val (label, container, content) = - when (status) { - NotificationChannels.ChannelStatus.ON -> - Triple( - R.string.notification_channel_status_on, - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.onSecondaryContainer, - ) - NotificationChannels.ChannelStatus.SILENT -> - Triple( - R.string.notification_channel_status_silent, - MaterialTheme.colorScheme.surfaceContainerHigh, - MaterialTheme.colorScheme.onSurfaceVariant, - ) - NotificationChannels.ChannelStatus.OFF -> - Triple( - R.string.notification_channel_status_off, - MaterialTheme.colorScheme.errorContainer, - MaterialTheme.colorScheme.onErrorContainer, - ) - } - - StatusChip(label = stringRes(label), containerColor = container, contentColor = content) + when (status) { + NotificationChannels.ChannelStatus.ON -> + StatusChip( + label = stringRes(R.string.notification_channel_status_on), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + NotificationChannels.ChannelStatus.SILENT -> + StatusChip( + label = stringRes(R.string.notification_channel_status_silent), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + NotificationChannels.ChannelStatus.OFF -> + StatusChip( + label = stringRes(R.string.notification_channel_status_off), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + } } @Composable diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3311e94ea4..385e0b61db 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1679,7 +1679,6 @@ In-app display Categories Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there. - Open Android notification settings On Silent Off