From e322c939cc89faed8990a8d8bd98a275a86deab5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 20:56:57 +0000 Subject: [PATCH 01/45] feat(desktop): load Blossom servers from kind 10063 like mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app read its Blossom media server list only from a local DesktopPreferences string (defaulting to blossom.primal.net) and never looked at the user's NIP-B7 BlossomServersEvent (kind 10063) — the same event the Amethyst mobile app loads via BlossomServerListState. A server list configured on mobile therefore never showed up on desktop. Load the list from the network event instead, mirroring the existing desktop NIP-65 flow: - Add a shared, platform-agnostic BlossomServerListState in commons that reads the kind-10063 addressable event from ICacheProvider and exposes a StateFlow> plus a save helper. - Store incoming kind-10063 events in DesktopLocalCache.route() (consumeBlossomServerList, newest-per-author wins). - Instantiate blossomServerList on DesktopIAccount and subscribe to kind 10063 in the account-config bootstrap subscription. - Mirror the loaded network list into DesktopPreferences so the upload path and cold start reflect it; the network event stays authoritative. - Feed the media-server settings screen from the network list and, on edit, sign+broadcast a new kind-10063 event so changes sync to every Amethyst client (writeable accounts only). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2 --- .../nipB7Blossom/BlossomServerListState.kt | 90 +++++++++++++++++ .../vitorpamplona/amethyst/desktop/Main.kt | 43 ++++++-- .../desktop/cache/DesktopLocalCache.kt | 26 +++++ .../amethyst/desktop/model/DesktopIAccount.kt | 9 ++ .../desktop/ui/deck/DeckColumnContainer.kt | 17 ++++ .../cache/DesktopBlossomServerListTest.kt | 99 +++++++++++++++++++ 6 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt new file mode 100644 index 0000000000..2d13cd22fe --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt @@ -0,0 +1,90 @@ +/* + * 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.commons.model.nipB7Blossom + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +/** + * Shared, platform-agnostic state holder for the user's Blossom media server + * list (NIP-B7 / kind 10063 [BlossomServersEvent]). + * + * This is the same event kind the Amethyst mobile app reads through its own + * `BlossomServerListState`: it loads the addressable event from the injected + * [ICacheProvider] and exposes the declared server URLs as a [StateFlow]. Both + * the Android and Desktop front ends can consume this so a server list + * configured on one client shows up on the other. + */ +class BlossomServerListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val blossomListNote = cache.getOrCreateAddressableNote(getBlossomServersAddress()) + + fun getBlossomServersAddress() = BlossomServersEvent.createAddress(signer.pubKey) + + fun getBlossomServersListFlow(): StateFlow = blossomListNote.flow().metadata.stateFlow + + fun getBlossomServersList(): BlossomServersEvent? = blossomListNote.event as? BlossomServersEvent + + fun normalizeServers(note: Note): List = (note.event as? BlossomServersEvent)?.servers() ?: emptyList() + + val flow: StateFlow> = + getBlossomServersListFlow() + .map { normalizeServers(it.note) } + .onStart { emit(normalizeServers(blossomListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun saveBlossomServersList(servers: List): BlossomServersEvent { + val serverList = getBlossomServersList() + + return if (serverList != null && serverList.tags.isNotEmpty()) { + BlossomServersEvent.updateRelayList( + earlierVersion = serverList, + servers = servers, + signer = signer, + ) + } else { + BlossomServersEvent.createFromScratch( + relays = servers, + signer = signer, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 90c52eeffe..d4c9671a2d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -148,6 +148,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.LogLevel import kotlinx.collections.immutable.toPersistentMap @@ -1687,9 +1688,10 @@ fun MainContent( ChatMessageRelayListEvent.KIND, SearchRelayListEvent.KIND, BlockedRelayListEvent.KIND, + BlossomServersEvent.KIND, ), authors = listOf(account.pubKeyHex), - limit = 4, + limit = 5, ) relayManager.subscribe( subId = bootstrapSubId, @@ -1702,9 +1704,11 @@ fun MainContent( relay: NormalizedRelayUrl, forFilters: List?, ) { - // NIP-65 (kind 10002) must go through justConsumeMyOwnEvent - // because localCache.consume() doesn't handle addressable events - if (event is AdvertisedRelayListEvent) { + // NIP-65 (kind 10002) and the Blossom server list + // (kind 10063) are addressable/replaceable events, so + // they must go through justConsumeMyOwnEvent to land in + // the addressable-note cache their state holders observe. + if (event is AdvertisedRelayListEvent || event is BlossomServersEvent) { scope.launch(Dispatchers.IO) { localCache.justConsumeMyOwnEvent(event) } @@ -1717,6 +1721,19 @@ fun MainContent( onDispose { relayManager.unsubscribe(bootstrapSubId) } } + // Mirror the network Blossom server list (kind 10063) into local prefs so + // the upload path (ComposeNoteDialog) and cold start reflect the list the + // user configured on any Amethyst client. Only overwrite with a non-empty + // network list — an empty flow value means the event hasn't loaded yet, and + // clobbering prefs then would wipe the user's offline fallback. + LaunchedEffect(iAccount) { + iAccount.blossomServerList.flow.collect { servers -> + if (servers.isNotEmpty() && servers != DesktopPreferences.blossomServers) { + DesktopPreferences.blossomServers = servers + } + } + } + // Subscribe to incoming DMs and process into chatroomList LaunchedEffect(account) { relayManager.connectedRelays.first { it.isNotEmpty() } @@ -2219,6 +2236,8 @@ fun RelaySettingsScreen( .TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF), onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {}, namecoinPreferences: DesktopNamecoinPreferences? = null, + blossomServers: kotlinx.coroutines.flow.StateFlow>? = null, + onBlossomServersChanged: (List) -> Unit = { DesktopPreferences.blossomServers = it }, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -2342,11 +2361,17 @@ fun RelaySettingsScreen( HorizontalDivider() Spacer(Modifier.height(24.dp)) - // Media Server Settings - MediaServerSettings( - initialServers = DesktopPreferences.blossomServers, - onServersChanged = { DesktopPreferences.blossomServers = it }, - ) + // Media Server Settings (Blossom, kind 10063 — synced with mobile) + val networkBlossomServers by (blossomServers?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) + // The kind-10063 list is authoritative when present; before it loads + // (or when the user has none) fall back to the local prefs mirror. + val effectiveBlossomServers = networkBlossomServers.ifEmpty { DesktopPreferences.blossomServers } + key(effectiveBlossomServers) { + MediaServerSettings( + initialServers = effectiveBlossomServers, + onServersChanged = onBlossomServersChanged, + ) + } Spacer(Modifier.height(24.dp)) HorizontalDivider() Spacer(Modifier.height(24.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index fc762421e9..d064648643 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -309,11 +310,36 @@ class DesktopLocalCache : ICacheProvider { consumeAdvertisedRelayList(event, relay) } + is BlossomServersEvent -> { + consumeBlossomServerList(event, relay) + } + else -> { false } } + /** + * Consumes a kind 10063 (NIP-B7) Blossom media server list event. Stores + * the newest per-author copy in [addressableNotes] so state holders like + * [com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState] + * observe it via their flows. This is the same event the Amethyst mobile + * app uses for the media server list. Emits nothing to the event stream — + * the UI doesn't render kind 10063s directly. + */ + private fun consumeBlossomServerList( + event: BlossomServersEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val addressableNote = getOrCreateAddressableNote(event.address()) + val existing = addressableNote.event + if (existing != null && existing.createdAt >= event.createdAt) return false + val author = getOrCreateUser(event.pubKey) + addressableNote.loadEvent(event, author, emptyList()) + relay?.let { addressableNote.addRelay(it) } + return false + } + /** * Consumes a kind 10002 (NIP-65) advertised relay list event. Stores * the newest per-author copy in [addressableNotes] so the outbox diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index e85c610398..1a64224c85 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRepository import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -85,6 +86,14 @@ class DesktopIAccount( val oldBookmarkState = OldBookmarkListState(signer, localCache, scope) val bookmarkState = BookmarkListState(signer, localCache, scope) + /** + * User's Blossom media server list (NIP-B7 / kind 10063). Loads from the + * same event kind the Amethyst mobile app uses, so a server list configured + * on mobile shows up here too. Backed by [localCache]; populated by the + * account-config subscription in Main.kt. + */ + val blossomServerList = BlossomServerListState(signer, localCache, scope) + val kind3FollowList = Kind3FollowListState( signer, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 19f2a8248e..7a4f369dd4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.desktop.ui.scheduledposts.DraftsAndScheduledSc import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch class ColumnNavigationState { private val _stack = mutableStateListOf() @@ -501,6 +502,22 @@ internal fun RootContent( torSettings = torState.settings, onTorSettingsChanged = torState.onSettingsChanged, namecoinPreferences = LocalNamecoinPreferences.current, + blossomServers = iAccount.blossomServerList.flow, + onBlossomServersChanged = { servers -> + // Local mirror for the upload path + cold start. + com.vitorpamplona.amethyst.desktop.DesktopPreferences.blossomServers = servers + // Publish a kind-10063 event so the list syncs to every + // Amethyst client, then consume it locally so state updates + // immediately (mirrors the NIP-65 save flow above). Read-only + // accounts can't sign, so keep the change local-only there. + if (iAccount.isWriteable()) { + appScope.launch { + val event = iAccount.blossomServerList.saveBlossomServersList(servers) + relayManager.broadcastToAll(event) + localCache.justConsumeMyOwnEvent(event) + } + } + }, ) } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt new file mode 100644 index 0000000000..6cdb574b23 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt @@ -0,0 +1,99 @@ +/* + * 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.desktop.cache + +import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * The desktop app must load the user's media server list from the same event + * kind the Amethyst mobile app uses — the NIP-B7 [BlossomServersEvent] + * (kind 10063). These tests verify that an incoming kind-10063 event lands in + * [DesktopLocalCache] and that the shared [BlossomServerListState] surfaces the + * declared servers. + */ +class DesktopBlossomServerListTest { + private val relayUrl = NormalizedRelayUrl("wss://relay.test/") + + private suspend fun signedServerList( + servers: List, + signer: NostrSignerInternal, + createdAt: Long = 1_700_000_000, + ): BlossomServersEvent = BlossomServersEvent.create(servers, signer, createdAt) + + @Test + fun `consume stores an incoming kind 10063 event in the addressable cache`() = + runTest { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + val servers = listOf("https://blossom.example.com", "https://cdn.example.org") + val event = signedServerList(servers, signer) + + cache.consume(event, relayUrl) + + val stored = cache.getOrCreateAddressableNote(event.address()).event as? BlossomServersEvent + assertNotNull(stored, "kind 10063 event must be stored in the addressable cache") + assertEquals(servers, stored.servers()) + } + + @Test + fun `an older event does not overwrite a newer one`() = + runTest { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + val newer = signedServerList(listOf("https://new.example.com"), signer, createdAt = 2_000) + val older = signedServerList(listOf("https://old.example.com"), signer, createdAt = 1_000) + + cache.consume(newer, relayUrl) + cache.consume(older, relayUrl) + + val stored = cache.getOrCreateAddressableNote(newer.address()).event as? BlossomServersEvent + assertEquals(listOf("https://new.example.com"), stored?.servers()) + } + + @Test + fun `BlossomServerListState surfaces the servers from the cached event`() = + runTest { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + val servers = listOf("https://blossom.example.com") + val event = signedServerList(servers, signer) + cache.consume(event, relayUrl) + + val state = + BlossomServerListState( + signer = signer, + cache = cache, + scope = backgroundScope, + ) + + assertEquals(servers, state.getBlossomServersList()?.servers()) + assertEquals(servers, state.flow.first { it.isNotEmpty() }) + } +} From 5c858a3a29a13b4f4de486f297f4542171d18893 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 20:59:10 +0000 Subject: [PATCH 02/45] feat(desktop): download NIP-17 DM relay lists proactively and back the User model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app resolves each recipient's kind:10050 DM inbox (strictly, no NIP-65 fallback) when sending NIP-17 messages and reactions, but only lazily at send/room-open time via the indexer fan-out — and recipient relay lists that arrived through the normal feed were being dropped, so the cache never helped. - Route kind:10050 (ChatMessageRelayListEvent) through DesktopLocalCache.route into addressableNotes, mirroring kind:10002. Previously route() dropped it. - Back the User model's pinned replaceable notes (kind 10002/10050/10019) with the same addressableNotes map that consume writes into. Before this, the desktop UserContext read a plain notes map that nothing populated, so User.dmInboxRelaysStrict()/outboxRelays() always returned null — leaving the DM inbox resolver's local lookup, the send-path fallback, and tier-1 AUTH for the user's own DM relays effectively dead. - Consume the user's own kind:10050 into LocalCache at bootstrap alongside its persisted accountRelays copy. - Prewarm each conversation peer's DM relay list as soon as their room appears in the list (concurrency-capped, deduped), so the first send/reaction resolves from cache and the composer's "no DM relays" gate settles early. Adds DesktopDmRelayListConsumeTest covering kind:10050 routing, newer/older replacement, and the unknown-recipient case. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3 --- .../vitorpamplona/amethyst/desktop/Main.kt | 9 +- .../desktop/cache/DesktopLocalCache.kt | 43 +++++++- .../desktop/ui/chats/ChatroomListState.kt | 56 ++++++++++ .../cache/DesktopDmRelayListConsumeTest.kt | 100 ++++++++++++++++++ 4 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopDmRelayListConsumeTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 90c52eeffe..9edf1cb95b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1702,9 +1702,12 @@ fun MainContent( relay: NormalizedRelayUrl, forFilters: List?, ) { - // NIP-65 (kind 10002) must go through justConsumeMyOwnEvent - // because localCache.consume() doesn't handle addressable events - if (event is AdvertisedRelayListEvent) { + // NIP-65 (kind 10002) and the NIP-17 DM relay list + // (kind 10050) are addressable; route them into + // LocalCache so the user's own routing lists back the + // User model (dmInboxRelaysStrict / self-copy resolution) + // alongside accountRelays' persisted copy. + if (event is AdvertisedRelayListEvent || event is ChatMessageRelayListEvent) { scope.launch(Dispatchers.IO) { localCache.justConsumeMyOwnEvent(event) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index fc762421e9..3ec2bc4f4f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.QAddressableTag @@ -141,12 +142,16 @@ class DesktopLocalCache : ICacheProvider { override fun getOrCreateUser(pubkey: HexKey): User = users.getOrCreate(pubkey) { User(pubkey, userContext) } /** - * [UserContext] bridge — desktop's note store is keyed on string ids - * rather than full addressable maps, so we synthesise a stable id - * from the Address. User's lazy fields hold the resulting Note for - * its lifetime, same pinning guarantee as on Android. + * [UserContext] bridge — resolves the [User]'s pinned replaceable notes + * (kind:10002 outbox, kind:10050 DM inbox, kind:10019 nutzap info) against + * the same [addressableNotes] map that [consume] writes into. Backing these + * with [getOrCreateAddressableNote] (not the plain [notes] map) is what lets + * `User.dmInboxRelaysStrict()` / `outboxRelays()` actually reflect events the + * cache has ingested — the DM send path, the resolver's local lookup, and + * tier-1 AUTH all read those accessors. User's lazy fields hold the + * resulting note for its lifetime, same pinning guarantee as on Android. */ - private val userContext = UserContext { addr -> getOrCreateNote(addr.toValue()) } + private val userContext = UserContext { addr -> getOrCreateAddressableNote(addr) } override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { key, user -> predicate(key, user) } @@ -309,6 +314,10 @@ class DesktopLocalCache : ICacheProvider { consumeAdvertisedRelayList(event, relay) } + is ChatMessageRelayListEvent -> { + consumeChatMessageRelayList(event, relay) + } + else -> { false } @@ -334,6 +343,30 @@ class DesktopLocalCache : ICacheProvider { return false } + /** + * Consumes a kind 10050 (NIP-17) DM relay-list event. Stores the newest + * per-author copy in [addressableNotes] so [User.dmInboxRelaysStrict] and + * the DM send path can resolve a recipient's inbox without a fresh REQ. + * + * Without this branch, [route] dropped kind:10050 entirely — recipient DM + * relay lists arriving through the normal feed / DM subscriptions never + * landed in the cache, forcing every send to re-discover them via the + * indexer fan-out. Mirrors [consumeAdvertisedRelayList]; emits nothing to + * the event stream because the UI doesn't render kind 10050s directly. + */ + private fun consumeChatMessageRelayList( + event: ChatMessageRelayListEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val addressableNote = getOrCreateAddressableNote(event.address()) + val existing = addressableNote.event + if (existing != null && existing.createdAt >= event.createdAt) return false + val author = getOrCreateUser(event.pubKey) + addressableNote.loadEvent(event, author, emptyList()) + relay?.let { addressableNote.addRelay(it) } + return false + } + /** * Returns the cached kind-10002 event for [pubkey], if any. Used by the * outbox dispatcher to skip a Phase-1 REQ for authors whose write-relay diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt index fc7dd95cc8..61ae9351a1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.privateChats.Chatroom import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -42,6 +43,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit /** * Represents a conversation entry in the list pane. @@ -101,6 +104,14 @@ class ChatroomListState( // Track pubkeys we've already requested metadata for private val fetchedMetadataKeys = mutableSetOf() + // Track pubkeys whose NIP-17 DM relay list (kind 10050) we've already + // prewarmed, so the 2s refresh loop only kicks off one fetch per peer. + private val prewarmedDmRelayKeys = mutableSetOf() + + // Bound concurrent kind:10050 lookups so a large conversation list doesn't + // flood the curated indexer relays with a burst of parallel fan-outs. + private val dmRelayPrewarmLimiter = Semaphore(4) + // Timestamp (createdAt) of the newest message seen by the user per room, set when a room is // opened. A room is unread when its newest incoming message is newer than this mark. private val lastSeen = mutableMapOf() @@ -195,6 +206,41 @@ class ChatroomListState( } } + /** + * Proactively download every conversation peer's NIP-17 DM relay list + * (kind 10050) as soon as their room shows up in the list, rather than + * waiting until the user opens the room or hits send. + * + * NIP-17 gift wraps (both messages and reactions) MUST be delivered to the + * recipient's kind:10050 relays; the desktop send path resolves those via + * [DesktopIAccount.dmInboxResolver]. Warming the resolver here means the + * first send/reaction resolves from cache instead of paying an indexer + * round-trip, and the composer's "recipient has no DM relays" gate settles + * before the user has typed anything. + * + * The resolver already dedupes and caches, but we also keep our own + * [prewarmedDmRelayKeys] guard so the 2s refresh loop launches at most one + * fetch per peer, and cap concurrency via [dmRelayPrewarmLimiter] so a big + * conversation list doesn't fan out to the indexers all at once. + */ + private fun prewarmDmRelayLists(pubkeys: Collection) { + val resolver = (account as? DesktopIAccount)?.dmInboxResolver ?: return + val needed = pubkeys.filter { it.length == 64 && prewarmedDmRelayKeys.add(it) } + if (needed.isEmpty()) return + + for (pubkey in needed) { + scope.launch(Dispatchers.IO) { + dmRelayPrewarmLimiter.withPermit { + try { + resolver.resolve(pubkey) + } catch (_: Exception) { + // Best-effort prefetch; the send path resolves again on demand. + } + } + } + } + } + private suspend fun refreshRooms() { val chatroomList = account.chatroomList val known = mutableListOf() @@ -207,6 +253,10 @@ class ChatroomListState( // Collect pubkeys needing metadata across all rooms val pubkeysNeedingMetadata = mutableListOf() + // Collect every peer pubkey so we can proactively download their NIP-17 + // DM relay list (kind 10050) — see [prewarmDmRelayLists]. + val dmPeerPubkeys = mutableSetOf() + for ((key, chatroom) in entries) { // Skip rooms with no messages if (chatroom.messages.isEmpty()) continue @@ -215,6 +265,8 @@ class ChatroomListState( val newestMessage = chatroom.newestMessage if (newestMessage != null && !account.isAcceptable(newestMessage)) continue + dmPeerPubkeys.addAll(key.users) + val users = key.users.mapNotNull { cacheProvider.getUserIfExists(it) } // Collect pubkeys without profile info @@ -265,6 +317,10 @@ class ChatroomListState( // Batch-request metadata for all users who need it fetchMetadataIfNeeded(pubkeysNeedingMetadata) + // Proactively download each peer's DM relay list (kind 10050) so the + // NIP-17 send path resolves it from cache instead of on demand. + prewarmDmRelayLists(dmPeerPubkeys) + // Sort by most recent message _knownRooms.value = known.sortedByDescending { it.lastMessageTimestamp } _newRooms.value = new.sortedByDescending { it.lastMessageTimestamp } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopDmRelayListConsumeTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopDmRelayListConsumeTest.kt new file mode 100644 index 0000000000..8d0cbd22ce --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopDmRelayListConsumeTest.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.desktop.cache + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Verifies that [DesktopLocalCache] routes a NIP-17 DM relay list (kind 10050) + * into the cache so the [com.vitorpamplona.amethyst.commons.model.User] model + * reflects it. The desktop NIP-17 send path, the DM inbox resolver's local + * lookup, and tier-1 AUTH all read `User.dmInboxRelaysStrict()`; before the + * kind:10050 routing + `UserContext` wiring, `route` dropped kind 10050 and the + * User model was backed by a map nothing populated, so every recipient looked + * "unreachable via NIP-17" until an indexer fan-out ran. + */ +class DesktopDmRelayListConsumeTest { + private val relayUrl = NormalizedRelayUrl("wss://relay.test/") + + private val dmInbox = + listOf( + NormalizedRelayUrl("wss://inbox1.example/"), + NormalizedRelayUrl("wss://inbox2.example/"), + ) + + private fun signedDmRelayList( + signer: NostrSignerSync, + relays: List = dmInbox, + createdAt: Long = 1_700_000_000, + ): ChatMessageRelayListEvent = ChatMessageRelayListEvent.create(relays, signer, createdAt) + + @Test + fun `consume routes kind 10050 into the User model`() { + val cache = DesktopLocalCache() + val signer = NostrSignerSync(KeyPair()) + val event = signedDmRelayList(signer) + + cache.consume(event, relayUrl) + + val user = cache.getOrCreateUser(signer.pubKey) + assertEquals(dmInbox, user.dmInboxRelaysStrict()) + } + + @Test + fun `an unknown recipient has no strict DM relays`() { + val cache = DesktopLocalCache() + val stranger = NostrSignerSync(KeyPair()).pubKey + + assertNull(cache.getOrCreateUser(stranger).dmInboxRelaysStrict()) + } + + @Test + fun `a newer kind 10050 replaces an older one`() { + val cache = DesktopLocalCache() + val signer = NostrSignerSync(KeyPair()) + + cache.consume(signedDmRelayList(signer, dmInbox, createdAt = 1_700_000_000), relayUrl) + + val newerRelays = listOf(NormalizedRelayUrl("wss://moved.example/")) + cache.consume(signedDmRelayList(signer, newerRelays, createdAt = 1_700_000_100), relayUrl) + + assertEquals(newerRelays, cache.getOrCreateUser(signer.pubKey).dmInboxRelaysStrict()) + } + + @Test + fun `an older kind 10050 does not overwrite a newer one`() { + val cache = DesktopLocalCache() + val signer = NostrSignerSync(KeyPair()) + + cache.consume(signedDmRelayList(signer, dmInbox, createdAt = 1_700_000_100), relayUrl) + + val staleRelays = listOf(NormalizedRelayUrl("wss://stale.example/")) + cache.consume(signedDmRelayList(signer, staleRelays, createdAt = 1_700_000_000), relayUrl) + + assertEquals(dmInbox, cache.getOrCreateUser(signer.pubKey).dmInboxRelaysStrict()) + } +} From 0d58d94ef4a895931b2c4fdd0ce764f3d8181e63 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:13:23 +0000 Subject: [PATCH 03/45] fix: resolve CI lint error and coroutines opt-in warnings - Collect ConcordSession.state via collectAsStateWithLifecycle instead of reading StateFlow.value in composition (fixes StateFlowValueCalledInComposition lint error in GroupBottomBarEntries.kt). - Add @OptIn(ExperimentalCoroutinesApi::class) for testScheduler.advanceTimeBy and runCurrent usages in ResourceUsageLedgerTest and RelayAuthPromptBusTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Wx8SvzsWsru7rEQ1KcbSCW --- .../ui/navigation/bottombars/GroupBottomBarEntries.kt | 9 +++------ .../authCommand/model/RelayAuthPromptBusTest.kt | 2 ++ .../service/resourceusage/ResourceUsageLedgerTest.kt | 2 ++ 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt index 242dcf9eef..eaac107c57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.flow.MutableStateFlow /** * The resolved presentation of a pinned chat/group [BottomBarEntry] — enough to render its avatar in @@ -153,12 +154,8 @@ fun rememberConcordEntryDisplay( val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle() val session = remember(entry.communityId, revision) { account.concordSessions.sessionFor(entry.communityId) } - val metadata = - session - ?.state - ?.value - .takeIf { revision >= 0 } - ?.metadata + val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() + val metadata = state.takeIf { revision >= 0 }?.metadata val fallbackName = remember(communities, entry.communityId) { communities.firstOrNull { it.id == entry.communityId }?.name?.ifBlank { null } } val label = metadata?.name?.takeIf { it.isNotBlank() } ?: fallbackName ?: stringRes(R.string.concord_home_title) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt index e3e71b44de..70f3a8d30f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runCurrent @@ -70,6 +71,7 @@ class RelayAuthPromptBusTest { assertEquals(UserAuthChoice.DISMISS, bus.requestDecision(relay, emptyList())) } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun retainsPromptForALateSubscriberSoItIsNotLost() = runTest { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 6be24d2942..ac4c066788 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -142,6 +143,7 @@ class ResourceUsageAccountantTest { assertEquals(12L, store.allDays()[200]?.get("x")) } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun hookAddsAreDrainedInPlaceAndNeverRearmTheFlushLoop() = runTest { From 7ecb1af13bcb7d8f30402b3a5b61977bb50ee2ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:57:11 +0000 Subject: [PATCH 04/45] feat: enrich Concord channel list rows with last message + unread counts The community "server" channel list (ConcordChannelListScreen) showed only an icon and name per channel. It now mirrors the chat-room list feel: each row carries a preview of the last message (author + snippet), the relative time of that message, and an unread-message count badge, with the name/icon/time emphasized while unread. Adds a shared, reactive unread-message counter (concordChannelUnreadCountFlow) that combines each channel's persisted last-read marker with its own note store, plus a shared ConcordUnreadBadge pill (capped at 99+, plural content description). The Concord Channels hub (ConcordHomeScreen) now reuses both: per-channel rows show the real new-message count instead of a bare dot, and a community's badge sums new messages across its channels. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e --- .../concord/ConcordChannelListScreen.kt | 150 +++++++++++++++--- .../concord/ConcordHomeScreen.kt | 56 ++----- .../publicChannels/concord/ConcordUnread.kt | 58 +++++++ .../concord/ConcordUnreadBadge.kt | 74 +++++++++ amethyst/src/main/res/values/strings.xml | 5 + 5 files changed, 278 insertions(+), 65 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 84121fe740..28992c8e58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -60,15 +60,21 @@ import androidx.compose.ui.text.font.FontWeight 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.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -254,28 +260,18 @@ fun ConcordChannelListScreen( def.private == true -> MaterialSymbols.Lock else -> MaterialSymbols.Tag } - Row( - Modifier - .fillMaxWidth() - .clickable { nav.nav(Route.Concord(communityId, entry.key)) } - .padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = if (canManageChannels) 4.dp else 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - SymbolIcon( - symbol = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text(name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1) - if (canManageChannels) { - ConcordChannelRowMenu( - onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, - onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, - ) - } - } + ConcordChannelListRow( + communityId = communityId, + channelKey = entry.key, + channelName = name, + icon = icon, + isVoice = def.voice == true, + canManageChannels = canManageChannels, + accountViewModel = accountViewModel, + onClick = { nav.nav(Route.Concord(communityId, entry.key)) }, + onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, + onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, + ) HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } @@ -283,6 +279,116 @@ fun ConcordChannelListScreen( } } +/** + * One channel row in the community's server view: its icon, name, a preview of the last message + * (author + snippet), the relative time of that message, and an unread-message count badge — plus + * the manager-only overflow menu. Name/icon come from the folded Control Plane definition; the + * preview, time, and unread count are read reactively from the channel's own message store so they + * fill in the moment messages fold in and clear as soon as the channel is opened (last-read advances). + */ +@Composable +private fun ConcordChannelListRow( + communityId: String, + channelKey: String, + channelName: String, + icon: MaterialSymbol, + isVoice: Boolean, + canManageChannels: Boolean, + accountViewModel: AccountViewModel, + onClick: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + val account = accountViewModel.account + // getOrCreate (not getIfExists): a channel folded on the Control Plane may have no message note + // yet; its notes flow then makes the preview/time/unread reactive as the first message arrives. + val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) } + val channelState by channel + .flow() + .notes.stateFlow + .collectAsStateWithLifecycle() + val lastNote = channelState.channel.lastNote + val unread by + remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) } + .collectAsStateWithLifecycle(0) + val hasUnread = unread > 0 + + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = if (canManageChannels) 4.dp else 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SymbolIcon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (hasUnread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(Modifier.weight(1f)) { + Text( + channelName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ConcordChannelPreviewLine(lastNote, isVoice, accountViewModel) + } + lastNote?.createdAt()?.let { ts -> + Text( + timeAgo(ts, LocalContext.current, prefix = ""), + style = MaterialTheme.typography.labelSmall, + color = if (hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + ConcordUnreadBadge(unread) + if (canManageChannels) { + ConcordChannelRowMenu( + onRename = onRename, + onDelete = onDelete, + ) + } + } +} + +/** + * The one-line message preview under a channel name: the last message's author and a snippet of its + * content ("author: hello"), or a muted "No messages yet" placeholder before anything has folded in. + * The author name resolves reactively, so it upgrades from a hex fallback to the profile name. + */ +@Composable +private fun ConcordChannelPreviewLine( + lastNote: Note?, + isVoice: Boolean, + accountViewModel: AccountViewModel, +) { + val event = lastNote?.event + val author = lastNote?.author + val preview: String = + if (event != null && author != null) { + val authorName by observeUserName(author, accountViewModel) + val body = event.content.take(80).ifBlank { "" } + if (body.isBlank()) authorName ?: "" else "${authorName ?: ""}: $body" + } else if (event != null) { + event.content.take(80) + } else { + // Voice channels never carry chat notes, so "No messages yet" would read oddly — leave blank. + if (isVoice) return + stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_no_messages) + } + Text( + preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + /** A pending channel create ([channelIdHex] null) or rename target. */ private data class ConcordChannelEditor( val channelIdHex: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index a368f2429c..34def3f473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -258,10 +257,9 @@ private fun communityActivity( } ?: 0L /** - * The number of a community's channels with a message newer than this account last read there — - * combines each channel's persisted last-read ([concordChannelLastReadRoute]) against its - * [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel.lastNote]. Recomputed on - * [revision] so a freshly-folded message flips the badge. + * The total number of new messages across a community's channels — the sum, over every channel, of + * messages newer than this account last read that channel ([concordChannelUnreadCountFlow]). + * Recomputed reactively as messages fold in and as channels are opened (last-read advances). */ @Composable private fun communityUnreadCount( @@ -271,23 +269,14 @@ private fun communityUnreadCount( ): Int { if (channelKeys.isEmpty()) return 0 // Keyed only on the channel set (not the global revision): each per-channel flow reacts to both - // its last-read marker AND the channel's own notes flow, so a folded message flips the badge + // its last-read marker AND the channel's own notes flow, so a folded message updates the badge // without tearing down and restarting every flow on every unrelated fold (which reset the badge // to 0 and made it flicker). val flow = remember(communityId, channelKeys) { combine( - channelKeys.map { key -> - val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, key)) - combine( - account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)), - channel.flow().notes.stateFlow, - ) { lastRead, state -> - val last = state.channel.lastNote?.createdAt() ?: 0L - if (last > lastRead) 1 else 0 - } - }, - ) { flags -> flags.sum() } + channelKeys.map { key -> concordChannelUnreadCountFlow(account, communityId, key) }, + ) { counts -> counts.sum() } } return flow.collectAsStateWithLifecycle(0).value } @@ -357,7 +346,7 @@ private fun CommunityHeader( ) } } - if (unread > 0) UnreadBadge(unread) + ConcordUnreadBadge(unread) SymbolIcon( // ▲ only when fully open; ▼ for both CLOSED and the UNREAD peek ("more to reveal"). symbol = if (mode == ChannelExpand.OPEN) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, @@ -388,25 +377,6 @@ private fun CommunityBanner( ) } -/** A small pill showing the unread-channel count next to a community. */ -@Composable -private fun UnreadBadge(count: Int) { - Box( - modifier = - Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - .padding(horizontal = 7.dp, vertical = 2.dp), - contentAlignment = Alignment.Center, - ) { - Text( - count.toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary, - ) - } -} - /** * One channel row with its last message (author + snippet), relative time, and an unread marker — * bold + a dot when there's a message newer than this account last read the channel. @@ -433,8 +403,10 @@ private fun ConcordChannelRow( .notes.stateFlow .collectAsStateWithLifecycle() val lastNote = channelState.channel.lastNote - val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle() - val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime + val unreadCount by + remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) } + .collectAsStateWithLifecycle(0) + val unread = unreadCount > 0 // In the UNREAD peek, a read channel simply isn't shown. if (hideIfRead && !unread) return @@ -486,12 +458,10 @@ private fun ConcordChannelRow( Text( timeAgo(ts, LocalContext.current, prefix = ""), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (unread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) } - if (unread) { - Box(Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) - } + ConcordUnreadBadge(unreadCount) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt new file mode 100644 index 0000000000..deb3d2835b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt @@ -0,0 +1,58 @@ +/* + * 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.chats.publicChannels.concord + +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +/** + * A reactive flow of how many messages in [communityId]/[channelKey] are newer than the + * timestamp this account last read that channel. It combines the persisted last-read marker + * ([concordChannelLastReadRoute]) with the channel's own notes flow, so the count updates both + * when a fresh message folds in and when the user opens the channel (which advances last-read). + * + * Uses `getOrCreate` (not `getIfExists`): a channel folded on the Control Plane may have no + * message-buffer note yet, and the flow must still exist so the badge appears the moment its + * first message lands. Counting straight off [ConcordChannel.notes] ignores the synthetic + * placeholder note (it is never added to that cache) and any note with no timestamp. + */ +fun concordChannelUnreadCountFlow( + account: Account, + communityId: String, + channelKey: String, +): Flow { + val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) + // The notes flow drives recomputation (each new message re-emits its channel state); the count + // itself reads the channel's own note store, so it survives the base-typed ChannelState.channel. + return combine( + account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)), + channel.flow().notes.stateFlow, + ) { lastRead, _ -> + channel.newMessagesSince(lastRead) + } +} + +/** The number of this channel's messages created strictly after [sinceSecs] (0 if none). */ +private fun ConcordChannel.newMessagesSince(sinceSecs: Long): Int = notes.count { _, note -> (note.createdAt() ?: 0L) > sinceSecs } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt new file mode 100644 index 0000000000..99bf36ab36 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt @@ -0,0 +1,74 @@ +/* + * 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.chats.publicChannels.concord + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R + +/** Counts above this render as "N+" so a very busy channel doesn't blow out the row. */ +private const val CONCORD_UNREAD_CAP = 99 + +/** + * A small pill showing an unread-message [count] (new messages since this account last read). + * Renders nothing when [count] is zero, so callers can place it unconditionally. Capped at + * [CONCORD_UNREAD_CAP]+ and carries a plural content description for screen readers. + */ +@Composable +fun ConcordUnreadBadge( + count: Int, + modifier: Modifier = Modifier, +) { + if (count <= 0) return + val label = if (count > CONCORD_UNREAD_CAP) "$CONCORD_UNREAD_CAP+" else count.toString() + val description = pluralStringResource(R.plurals.concord_unread_messages, count, count) + Box( + modifier = + modifier + .semantics { contentDescription = description } + .sizeIn(minWidth = 20.dp, minHeight = 20.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e0a84dd566..d8573277d6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -341,6 +341,11 @@ %1$d member %1$d members + + %1$d new message + %1$d new messages + + No messages yet Create Invite people Invite link From dbb8cc522ed68e15e81477ff1d1d17d05b560577 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 20:32:18 +0000 Subject: [PATCH 05/45] =?UTF-8?q?feat:=20make=20Concord=20channel=20rows?= =?UTF-8?q?=20feel=20alive=20=E2=80=94=20facepile,=20live=20typing,=20anim?= =?UTF-8?q?ated=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the last commit to push the server channel list beyond a plain messaging list: - Recent-poster facepile: a stack of overlapping avatars (newest on top) for each channel's latest distinct authors, so a busy channel reads as "who's here" at a glance. New ConcordAuthorFacepile reuses the existing avatar primitives; recentAuthorHexes() picks the most-recent distinct posters in one O(notes) pass. - Live "X is typing…": each row consumes the community session's typing heartbeats (kind 23311) and swaps its preview line for an italic typing indicator, driven by one screen-level freshness ticker that only spins while a heartbeat is live. - Rows are now a two-line layout (name + facepile / preview + time + badge), and the unread badge animateContentSizes as its count changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e --- .../concord/ConcordChannelListScreen.kt | 129 ++++++++++++++---- .../publicChannels/concord/ConcordFacepile.kt | 84 ++++++++++++ .../publicChannels/concord/ConcordUnread.kt | 20 +++ .../concord/ConcordUnreadBadge.kt | 4 + 4 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordFacepile.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 28992c8e58..c68fcb05fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -47,7 +47,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -56,6 +58,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -63,6 +66,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.components.util.setText @@ -76,6 +80,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -100,6 +107,21 @@ fun ConcordChannelListScreen( val state by (session?.state ?: remember { kotlinx.coroutines.flow.MutableStateFlow(null) }) .collectAsStateWithLifecycle() + // Live typing heartbeats (kind 23311) per channel, so a channel where someone is composing shows + // "typing…" in place of its last-message preview. A single screen-level ticker re-applies the + // freshness window for every row at once, and only spins while at least one heartbeat is live. + val typingMap by (session?.typing ?: remember { kotlinx.coroutines.flow.MutableStateFlow(emptyMap>()) }) + .collectAsStateWithLifecycle() + var typingNow by remember { mutableLongStateOf(TimeUtils.now()) } + LaunchedEffect(typingMap) { + if (typingMap.values.all { it.isEmpty() }) return@LaunchedEffect + while (true) { + typingNow = TimeUtils.now() + if (typingMap.values.all { per -> per.values.none { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS } }) break + delay(2000L) + } + } + val scope = rememberCoroutineScope() var inviteLink by remember { mutableStateOf(null) } var minting by remember { mutableStateOf(false) } @@ -260,12 +282,20 @@ fun ConcordChannelListScreen( def.private == true -> MaterialSymbols.Lock else -> MaterialSymbols.Tag } + val typingAuthors = + remember(typingMap, typingNow, entry.key) { + (typingMap[entry.key] ?: emptyMap()) + .filterValues { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS } + .keys + .sorted() + } ConcordChannelListRow( communityId = communityId, channelKey = entry.key, channelName = name, icon = icon, isVoice = def.voice == true, + typingAuthors = typingAuthors, canManageChannels = canManageChannels, accountViewModel = accountViewModel, onClick = { nav.nav(Route.Concord(communityId, entry.key)) }, @@ -293,6 +323,7 @@ private fun ConcordChannelListRow( channelName: String, icon: MaterialSymbol, isVoice: Boolean, + typingAuthors: List, canManageChannels: Boolean, accountViewModel: AccountViewModel, onClick: () -> Unit, @@ -301,7 +332,7 @@ private fun ConcordChannelListRow( ) { val account = accountViewModel.account // getOrCreate (not getIfExists): a channel folded on the Control Plane may have no message note - // yet; its notes flow then makes the preview/time/unread reactive as the first message arrives. + // yet; its notes flow then makes the preview/time/unread/facepile reactive as messages arrive. val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) } val channelState by channel .flow() @@ -312,6 +343,8 @@ private fun ConcordChannelListRow( remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) } .collectAsStateWithLifecycle(0) val hasUnread = unread > 0 + // The recent posters' faces — recomputed as the channel's notes change (keyed on channelState). + val faceAuthors = remember(channelState) { channel.recentAuthorHexes(FACEPILE_MAX) } Row( Modifier @@ -327,25 +360,35 @@ private fun ConcordChannelListRow( modifier = Modifier.size(20.dp), tint = if (hasUnread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, ) - Column(Modifier.weight(1f)) { - Text( - channelName, - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - ConcordChannelPreviewLine(lastNote, isVoice, accountViewModel) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + // Line 1: channel name + the recent-posters facepile pushed to the right. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + channelName, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ConcordAuthorFacepile(faceAuthors, accountViewModel) + } + // Line 2: the last-message preview (or a live "typing…"), then the time + unread badge. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.weight(1f)) { + ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel) + } + lastNote?.createdAt()?.let { ts -> + Text( + timeAgo(ts, LocalContext.current, prefix = ""), + style = MaterialTheme.typography.labelSmall, + color = if (hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + ConcordUnreadBadge(unread) + } } - lastNote?.createdAt()?.let { ts -> - Text( - timeAgo(ts, LocalContext.current, prefix = ""), - style = MaterialTheme.typography.labelSmall, - color = if (hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - ConcordUnreadBadge(unread) if (canManageChannels) { ConcordChannelRowMenu( onRename = onRename, @@ -355,24 +398,51 @@ private fun ConcordChannelListRow( } } +/** How many recent-poster avatars a channel row's facepile shows at most. */ +private const val FACEPILE_MAX = 4 + /** - * The one-line message preview under a channel name: the last message's author and a snippet of its - * content ("author: hello"), or a muted "No messages yet" placeholder before anything has folded in. - * The author name resolves reactively, so it upgrades from a hex fallback to the profile name. + * The line under a channel name. When someone is composing it shows a live italic "X is typing…"; + * otherwise the last message's author + a snippet ("author: hello"), or a muted "No messages yet" + * placeholder before anything has folded in. Author names resolve reactively (hex → profile name). */ @Composable private fun ConcordChannelPreviewLine( lastNote: Note?, isVoice: Boolean, + typingAuthors: List, accountViewModel: AccountViewModel, ) { + if (typingAuthors.isNotEmpty()) { + val label = + when (typingAuthors.size) { + 1 -> stringRes(com.vitorpamplona.amethyst.R.string.concord_typing_one, rememberConcordDisplayName(typingAuthors[0], accountViewModel)) + 2 -> + stringRes( + com.vitorpamplona.amethyst.R.string.concord_typing_two, + rememberConcordDisplayName(typingAuthors[0], accountViewModel), + rememberConcordDisplayName(typingAuthors[1], accountViewModel), + ) + else -> stringRes(com.vitorpamplona.amethyst.R.string.concord_typing_many) + } + Text( + label, + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + return + } + val event = lastNote?.event val author = lastNote?.author val preview: String = if (event != null && author != null) { val authorName by observeUserName(author, accountViewModel) - val body = event.content.take(80).ifBlank { "" } - if (body.isBlank()) authorName ?: "" else "${authorName ?: ""}: $body" + val body = event.content.take(80) + if (body.isBlank()) authorName else "$authorName: $body" } else if (event != null) { event.content.take(80) } else { @@ -389,6 +459,17 @@ private fun ConcordChannelPreviewLine( ) } +/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */ +@Composable +private fun rememberConcordDisplayName( + hex: HexKey, + accountViewModel: AccountViewModel, +): String { + val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) } + val name by observeUserName(user, accountViewModel) + return name +} + /** A pending channel create ([channelIdHex] null) or rename target. */ private data class ConcordChannelEditor( val channelIdHex: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordFacepile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordFacepile.kt new file mode 100644 index 0000000000..328458adc2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordFacepile.kt @@ -0,0 +1,84 @@ +/* + * 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.chats.publicChannels.concord + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor +import com.vitorpamplona.amethyst.ui.note.ObserveAndDrawInnerUserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A horizontal stack of overlapping avatars for the recent posters in a channel — the "who's here" + * cue that makes a busy channel feel alive. Each avatar wears a thin ring in the row's background + * colour so the overlap reads as a deck of cards; the newest poster sits on top (leftmost, highest + * z-index). Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally. + */ +@Composable +fun ConcordAuthorFacepile( + authorHexes: List, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + avatarSize: Dp = 22.dp, + maxShown: Int = 4, +) { + if (authorHexes.isEmpty()) return + val shown = authorHexes.take(maxShown) + // A ring one-and-a-half dp wide, and each avatar pulled left over its neighbour by a third of + // its width — enough overlap to read as a stack without hiding faces. + val ring = 1.5.dp + val overlap = avatarSize * 0.36f + Row(modifier, horizontalArrangement = Arrangement.spacedBy(-overlap)) { + shown.forEachIndexed { index, hex -> + Box( + Modifier + // Newest (index 0) on top so it isn't clipped by the one after it. + .zIndex((shown.size - index).toFloat()) + .size(avatarSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .padding(ring), + ) { + LoadUser(baseUserHex = hex, accountViewModel) { user -> + if (user != null) { + ObserveAndDrawInnerUserPicture(user, avatarSize - ring * 2, accountViewModel) + } else { + DisplayBlankAuthor(avatarSize - ring * 2, Modifier, accountViewModel) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt index deb3d2835b..a453cc031f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -56,3 +57,22 @@ fun concordChannelUnreadCountFlow( /** The number of this channel's messages created strictly after [sinceSecs] (0 if none). */ private fun ConcordChannel.newMessagesSince(sinceSecs: Long): Int = notes.count { _, note -> (note.createdAt() ?: 0L) > sinceSecs } + +/** + * The pubkeys of the [limit] most-recent distinct posters in this channel, newest first — the + * facepile shown on a channel row. One O(notes) pass keeps each author's latest post time, so a + * chatty author counts once (at their newest message) rather than crowding out quieter voices. + */ +fun ConcordChannel.recentAuthorHexes(limit: Int): List { + val latestByAuthor = HashMap() + for (note in notes.values()) { + val author = note.author?.pubkeyHex ?: continue + val at = note.createdAt() ?: continue + val prev = latestByAuthor[author] + if (prev == null || at > prev) latestByAuthor[author] = at + } + return latestByAuthor.entries + .sortedByDescending { it.value } + .take(limit) + .map { it.key } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt index 99bf36ab36..7b321b32af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding @@ -58,6 +59,9 @@ fun ConcordUnreadBadge( modifier = modifier .semantics { contentDescription = description } + // Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of + // snapping — a small touch that makes new activity feel noticed. + .animateContentSize() .sizeIn(minWidth = 20.dp, minHeight = 20.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary) From 9ee6f0dbdedcf0172a61551e5dabf6fb713c3080 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 20:58:03 +0000 Subject: [PATCH 06/45] feat(nip29): message pinning for relay groups Implements NIP-29 message pinning (nostr-protocol/nips#2379): Protocol (quartz): - GroupPinnedEvent (kind 39005): relay-signed pinned-message list, `d` group id + ordered `e` ids. - UpdatePinListEvent (kind 9010): moderator `update-pin-list` write, carries the full list so pin/unpin/reorder/clear are one submission. - Register both in EventFactory; add a pinnedEventIds tag helper. Model + cache: - RelayGroupChannel now folds the pin list (pinnedEventIds / isPinned) with the same createdAt-supersede guard as the roster. - LocalCache consumes 39005 into the channel and stores the 9010 write; 39005 added to the group's metadata REQ filter so pins load. Publish path: - Account.pin/unpin/updateRelayGroupPins + AccountViewModel wrappers. UI (non-intrusive, self-hiding): - Collapsed pinned-message bar under the top bar: shows the current pin, N-count cycling, tap to jump to the message in-feed (hoisted jump request threaded through the shared chat feed view). Renders nothing when the group has no pins. - Moderator-only Pin/Unpin action under "Show more" in the chat message bottom drawer, gated on membership.canModerate(). - Small pin glyph on pinned bubbles' footer. Tests: quartz build/parse round-trip for both kinds; channel pin-fold supersede/replace/clear semantics. --- .../vitorpamplona/amethyst/model/Account.kt | 32 ++++ .../amethyst/model/LocalCache.kt | 24 +++ .../ui/screen/loggedIn/AccountViewModel.kt | 10 ++ .../loggedIn/chats/feed/ChatFeedView.kt | 29 ++++ .../chats/feed/ChatMessageActionSheet.kt | 46 +++++ .../screen/loggedIn/chats/feed/ChatTimeAgo.kt | 23 ++- .../FilterMetadataToRelayGroup.kt | 4 +- .../relayGroup/RelayGroupChannelView.kt | 30 +++- .../relayGroup/RelayGroupPinnedBar.kt | 158 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 4 + .../nip29RelayGroups/RelayGroupChannel.kt | 20 +++ .../nip29RelayGroups/RelayGroupChannelTest.kt | 65 +++++++ .../metadata/GroupPinnedEvent.kt | 69 ++++++++ .../moderation/TagArrayExt.kt | 2 + .../moderation/UpdatePinListEvent.kt | 64 +++++++ .../quartz/utils/EventFactory.kt | 4 + .../quartz/nip29RelayGroups/PinEventsTest.kt | 83 +++++++++ 17 files changed, 657 insertions(+), 10 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt 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 2a30c95093..ab22669284 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -255,6 +255,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent @@ -2757,6 +2758,37 @@ class Account( signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } + /** + * Replace the group's pinned-message list with a kind 9010 update-pin-list event + * (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and + * republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. + */ + suspend fun updateRelayGroupPins( + channel: RelayGroupChannel, + pinnedEventIds: List, + ) { + val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds) + signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** Pin [eventId] by appending it to the current list (no-op if already pinned). */ + suspend fun pinRelayGroupMessage( + channel: RelayGroupChannel, + eventId: HexKey, + ) { + if (channel.isPinned(eventId)) return + updateRelayGroupPins(channel, channel.pinnedEventIds + eventId) + } + + /** Unpin [eventId] by removing it from the current list (no-op if not pinned). */ + suspend fun unpinRelayGroupMessage( + channel: RelayGroupChannel, + eventId: HexKey, + ) { + if (!channel.isPinned(eventId)) return + updateRelayGroupPins(channel, channel.pinnedEventIds - eventId) + } + /** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */ suspend fun removeRelayGroupUser( channel: RelayGroupChannel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index c7914c418c..f9b9e9d3c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -162,6 +162,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent @@ -170,6 +171,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent @@ -1895,6 +1897,20 @@ object LocalCache : ILocalCache, ICacheProvider { return new } + /** NIP-29 relay-signed pinned-message list (kind 39005) → the group's pins. */ + fun consume( + event: GroupPinnedEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val new = consumeBaseReplaceable(event, relay, wasVerified) + if (relay != null) { + val latest = getOrCreateAddressableNote(event.address()).event as? GroupPinnedEvent + latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updatePinned(it) } + } + return new + } + /** * Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, … * carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic @@ -3848,6 +3864,10 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is GroupPinnedEvent -> { + consume(event, relay, wasVerified) + } + // Remaining NIP-29 relay-group kinds. The two relay-signed addressables (39003 // roles, 39004 AV participants) are durable group state alongside 39000/39001/39002, // so they're stored replaceably. The 9xxx moderation actions and join/leave requests @@ -3878,6 +3898,10 @@ object LocalCache : ILocalCache, ICacheProvider { consumeRegularEvent(event, relay, wasVerified) } + is UpdatePinListEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + is DeleteGroupEvent -> { consumeRegularEvent(event, relay, wasVerified) } 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 a0c9adc1a1..2fc75b1839 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 @@ -1618,6 +1618,16 @@ class AccountViewModel( body: String, ) = launchSigner { account.postRelayGroupThread(channel, title, body) } + fun pinRelayGroupMessage( + channel: RelayGroupChannel, + note: Note, + ) = launchSigner { account.pinRelayGroupMessage(channel, note.idHex) } + + fun unpinRelayGroupMessage( + channel: RelayGroupChannel, + note: Note, + ) = launchSigner { account.unpinRelayGroupMessage(channel, note.idHex) } + fun removeRelayGroupUser( channel: RelayGroupChannel, pubkey: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index f3b7d11a4c..6ce084fb51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -74,6 +75,11 @@ fun RefreshingChatroomFeedView( // LazyColumn), so a caller (private DMs) can drive demand-driven paging off viewport visibility // rather than per-row composition. No-op for callers that don't paginate. sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, + // Optional external jump request (e.g. a "pinned message" bar): when it holds a note id, the feed + // scrolls to that message and highlights it, then calls [onJumpHandled] to clear it. Null for + // callers with no external jump affordance. + jumpToNoteId: State? = null, + onJumpHandled: () -> Unit = {}, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> listStateObserver(listState) @@ -89,6 +95,8 @@ fun RefreshingChatroomFeedView( olderBoundary, markersInGap, sentinels, + jumpToNoteId, + onJumpHandled, ) } } @@ -106,6 +114,8 @@ fun RenderChatFeedView( olderBoundary: (@Composable () -> Unit)? = null, markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, + jumpToNoteId: State? = null, + onJumpHandled: () -> Unit = {}, ) { val feedState by feed.feedContent.collectAsStateWithLifecycle() @@ -136,6 +146,8 @@ fun RenderChatFeedView( olderBoundary, markersInGap, sentinels, + jumpToNoteId, + onJumpHandled, ) } } @@ -155,6 +167,8 @@ fun ChatFeedLoaded( olderBoundary: (@Composable () -> Unit)? = null, markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, + jumpToNoteId: State? = null, + onJumpHandled: () -> Unit = {}, ) { val items by loaded.feed.collectAsStateWithLifecycle() @@ -180,6 +194,21 @@ fun ChatFeedLoaded( } } + // External jump request (pinned-message bar). Re-runs when the requested id changes or the loaded + // list shifts (so a pin that arrives after the tap still scrolls once loaded). Clears the request + // as soon as it lands, so a repeat tap on the same pin fires again. + val jumpId = jumpToNoteId?.value + LaunchedEffect(jumpId, items.list) { + if (jumpId != null) { + val index = items.list.indexOfFirst { it.idHex == jumpId } + if (index >= 0) { + listState.animateScrollToItem(index) + highlightedNoteId.value = jumpId + onJumpHandled() + } + } + } + LazyColumn( contentPadding = FeedPadding, modifier = Modifier.fillMaxSize(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index b6f9e307ae..3fbd768630 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -59,9 +59,11 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -323,6 +325,13 @@ fun ChatMessageActionSheet( } } } + + // NIP-29 moderator action: pin/unpin this message in its relay group. + // Only rendered (and only subscribes) when the note belongs to a group. + val relayGroup = remember(note) { note.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel } } + if (relayGroup != null && !note.isDraft()) { + RelayGroupPinTile(note, relayGroup, onDismiss, accountViewModel) + } } } @@ -331,6 +340,43 @@ fun ChatMessageActionSheet( } } +/** + * NIP-29 moderator-only tile that pins/unpins this message in its relay group. + * Observes the group's live roster + pin list, so it shows the right verb and + * hides itself entirely for members who can't moderate. Pinning replaces the whole + * kind-9010 list; the relay republishes the kind-39005 pins the bar reads. + */ +@Composable +private fun RelayGroupPinTile( + note: Note, + baseChannel: RelayGroupChannel, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, +) { + val channelState by observeChannel(baseChannel, accountViewModel) + val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel + + val canModerate = channel.membershipOf(accountViewModel.userProfile().pubkeyHex).canModerate() + if (!canModerate) return + + val pinned = channel.isPinned(note.idHex) + + SectionDivider() + TileRow { + if (pinned) { + ActionTile(MaterialSymbols.PushPin, stringRes(R.string.relay_group_unpin_message)) { + accountViewModel.unpinRelayGroupMessage(channel, note) + onDismiss() + } + } else { + ActionTile(MaterialSymbols.PushPin, stringRes(R.string.relay_group_pin_message)) { + accountViewModel.pinRelayGroupMessage(channel, note) + onDismiss() + } + } + } +} + /** * The expand/collapse control between the always-visible quick actions and the full * action inventory. Keeps the sheet short on open (the common case is a reaction, a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt index cddb259cd9..1c97a4d7fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation @@ -78,9 +79,13 @@ fun chatFooterHasMeta(note: Note): Boolean { return event is PrivateDmEvent || event.expiration() != null || event.geoHashOrScope() != null || - event.strongPoWOrNull() != null + event.strongPoWOrNull() != null || + note.isPinnedInRelayGroup() } +/** True when this note's NIP-29 group has it in its pinned list (kind 39005). */ +fun Note.isPinnedInRelayGroup(): Boolean = inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.isPinned(idHex) == true + /** * The small row at the bottom of a chat bubble: inline status glyphs (legacy-DM, * expiration, geohash, proof-of-work — each shown only when present) followed, on the @@ -102,6 +107,7 @@ fun ChatMessageFooter( Row(verticalAlignment = Alignment.CenterVertically) { // Each glyph self-gates and renders nothing when not applicable. + ChatPinnedBadge(note) IncognitoBadge(note) ChatExpiration(note) @@ -115,7 +121,7 @@ fun ChatMessageFooter( } if (showTime) { - val hasGlyph = event is PrivateDmEvent || event?.expiration() != null || geo != null || pow != null + val hasGlyph = note.isPinnedInRelayGroup() || event is PrivateDmEvent || event?.expiration() != null || geo != null || pow != null if (hasGlyph) Spacer(StdHorzSpacer) // Drafts aren't published, so no relay/delivery detail; everything else gets @@ -129,6 +135,19 @@ fun ChatMessageFooter( } } +/** A small pin glyph on a bubble whose message is pinned in its NIP-29 group. */ +@Composable +fun ChatPinnedBadge(note: Note) { + if (!note.isPinnedInRelayGroup()) return + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = stringRes(R.string.relay_group_pinned_content_description), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(StdHorzSpacer) +} + @Composable fun ChatExpiration(note: Note) { val event = note.event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt index a9c208ca76..1e62bbbe40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt @@ -27,15 +27,17 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent -/** Relay-signed group directory kinds: metadata + admins + members + roles. */ +/** Relay-signed group directory kinds: metadata + admins + members + roles + pins. */ private val RELAY_GROUP_METADATA_KINDS = listOf( GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, SupportedRolesEvent.KIND, + GroupPinnedEvent.KIND, ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index b84c48923e..befde7fc84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -32,6 +32,7 @@ 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.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -128,7 +129,26 @@ private fun ChannelView( WatchLifecycleAndUpdateModel(feedViewModel) ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel) + // Collect the metadata flow once for the whole screen: it drives both the pinned-message + // bar (kind-39005 pins) and the composer gating (roster membership), and updates the moment + // a pin lands or my join is accepted. + val channelState by channel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val liveChannel = channelState.channel as? RelayGroupChannel ?: channel + + // A pinned-bar tap requests an in-feed jump; the feed consumes it, scrolls + highlights, + // then clears it back to null so the same pin can be tapped again later. + val jumpToNoteId = remember { mutableStateOf(null) } + Column(Modifier.fillMaxHeight()) { + RelayGroupPinnedBar( + channel = liveChannel, + accountViewModel = accountViewModel, + onJumpToNote = { jumpToNoteId.value = it.idHex }, + ) + Column( modifier = remember { @@ -146,19 +166,15 @@ private fun ChannelView( avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, + jumpToNoteId = jumpToNoteId, + onJumpHandled = { jumpToNoteId.value = null }, ) } Spacer(modifier = DoubleVertSpacer) // NIP-29 relays reject writes from non-members, so only show the composer when the - // relay-signed roster (39001/39002) lists me as a member/mod/admin. Collect the metadata - // flow so the composer appears the moment my join is accepted. Otherwise, explain why. - val channelState by channel - .flow() - .metadata.stateFlow - .collectAsStateWithLifecycle() - val liveChannel = channelState.channel as? RelayGroupChannel ?: channel + // relay-signed roster (39001/39002) lists me as a member/mod/admin. Otherwise, explain why. val canPost = liveChannel.membershipOf(accountViewModel.userProfile().pubkeyHex).isMember() if (canPost) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt new file mode 100644 index 0000000000..1649276dac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupPinnedBar.kt @@ -0,0 +1,158 @@ +/* + * 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.chats.publicChannels.relayGroup + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness + +/** + * Self-hiding NIP-29 pinned-message bar shown under the group's top bar. Renders + * nothing when the group has no pins (kind-39005 empty), so it never obstructs a + * normal chat. When there are pins it shows a single-line preview of the current + * one; tapping [onJumpToNote] scrolls the feed to that message and, if the group + * has more than one pin, advances to the next for the following tap (Telegram-style + * cycling) rather than piling every pin on screen at once. + */ +@Composable +fun RelayGroupPinnedBar( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + onJumpToNote: (Note) -> Unit, +) { + val pinnedIds = channel.pinnedEventIds + if (pinnedIds.isEmpty()) return + + // Newest pin (last in the relay's display order) surfaced first; reset when the list changes. + var index by remember(pinnedIds) { mutableIntStateOf(pinnedIds.lastIndex) } + val safeIndex = index.coerceIn(0, pinnedIds.lastIndex) + val currentId = pinnedIds[safeIndex] + + val note = remember(currentId) { accountViewModel.checkGetOrCreateNote(currentId) } ?: return + + // Fetch + observe the pinned message so its author and content fill in once it loads. + val noteState by observeNote(note, accountViewModel) + val liveNote = noteState.note + + Surface( + color = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + onJumpToNote(liveNote) + // Cycle to the previous (older) pin so a repeat tap walks the history. + if (pinnedIds.size > 1) { + index = if (safeIndex == 0) pinnedIds.lastIndex else safeIndex - 1 + } + }.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = stringRes(R.string.relay_group_pinned_content_description), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringRes(R.string.relay_group_pinned_label), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + if (pinnedIds.size > 1) { + Text( + text = "${safeIndex + 1}/${pinnedIds.size}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Text( + text = pinnedPreview(liveNote) ?: stringRes(R.string.relay_group_pinned_content_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + HorizontalDivider(thickness = DividerThickness, color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + } + } +} + +/** + * A one-line "Author: message" preview for the bar, or just the message when the + * author's name hasn't loaded. Null when the pinned event body isn't loaded yet, so + * the caller can fall back to a generic label. + */ +private fun pinnedPreview(note: Note): String? { + val content = + note.event + ?.content + ?.replace('\n', ' ') + ?.trim() + val author = note.author?.toBestDisplayName() + return when { + content.isNullOrBlank() -> author + author != null -> "$author: $content" + else -> content + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e0a84dd566..eb0f14324e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2161,6 +2161,10 @@ Members Edit group Threads + Pin message + Unpin message + Pinned + Pinned messages Open group No groups on this relay yet. This relay does not advertise support for groups (NIP-29), so it may not host any. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 4a05bfa61c..aa702e13c5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow @@ -70,6 +71,14 @@ class RelayGroupChannel( private set private var adminsUpdatedAt: Long = 0 + /** + * Relay-signed pinned message ids (kind 39005), in the relay's display order. + * Pinning replaces the whole list, so this is always the full current set. + */ + var pinnedEventIds: List = emptyList() + private set + private var pinnedUpdatedAt: Long = 0 + /** * Members ∪ admins, recomputed only when a roster event lands. [memberCount] and the discovery * feed read this per note / per recomposition, so caching it avoids rebuilding the set each read. @@ -158,6 +167,17 @@ class RelayGroupChannel( updateChannelInfo() } + fun updatePinned(event: GroupPinnedEvent) { + // Only newer lists supersede; equal-or-older is dropped so a duplicate + // arrival isn't reprocessed (no redundant emit). + if (event.createdAt <= pinnedUpdatedAt) return + pinnedEventIds = event.pinnedEventIds() + pinnedUpdatedAt = event.createdAt + updateChannelInfo() + } + + fun isPinned(eventId: HexKey): Boolean = eventId in pinnedEventIds + /** * The shareable NIP-19 `naddr` coordinate for this group's metadata (kind * 39000, authored by the relay's own key, with the host relay as a hint), or diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt index d346cfaabf..6d54bf43bb 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.utils.EventFactory import kotlin.test.Test import kotlin.test.assertEquals @@ -79,6 +80,70 @@ class RelayGroupChannelTest { return EventFactory.create("00".repeat(32), relaySelf, createdAt, GroupAdminsEvent.KIND, tags, "", "22".repeat(64)) as GroupAdminsEvent } + private val msg1 = "11".repeat(32) + private val msg2 = "22".repeat(32) + private val msg3 = "33".repeat(32) + + private fun pinned( + createdAt: Long, + vararg eventIds: String, + ): GroupPinnedEvent { + val tags = (listOf(arrayOf("d", gid)) + eventIds.map { arrayOf("e", it) }).toTypedArray() + return EventFactory.create("00".repeat(32), relaySelf, createdAt, GroupPinnedEvent.KIND, tags, "", "22".repeat(64)) as GroupPinnedEvent + } + + @Test + fun pinnedListFoldsInOrderAndReportsMembership() { + val c = channel() + assertTrue(c.pinnedEventIds.isEmpty()) + assertFalse(c.isPinned(msg1)) + + c.updatePinned(pinned(100, msg1, msg2)) + + assertEquals(listOf(msg1, msg2), c.pinnedEventIds) + assertTrue(c.isPinned(msg1)) + assertTrue(c.isPinned(msg2)) + assertFalse(c.isPinned(msg3)) + } + + @Test + fun pinListIsFullyReplacedByNewerEvent() { + val c = channel() + c.updatePinned(pinned(100, msg1, msg2)) + // A newer list drops msg1, keeps msg2, adds msg3 — the whole set is replaced. + c.updatePinned(pinned(200, msg2, msg3)) + + assertEquals(listOf(msg2, msg3), c.pinnedEventIds) + assertFalse(c.isPinned(msg1)) + assertTrue(c.isPinned(msg3)) + } + + @Test + fun clearingPinsWithEmptyNewerList() { + val c = channel() + c.updatePinned(pinned(100, msg1)) + c.updatePinned(pinned(200)) + assertTrue(c.pinnedEventIds.isEmpty()) + assertFalse(c.isPinned(msg1)) + } + + @Test + fun stalePinEventDoesNotSupersede() { + val c = channel() + c.updatePinned(pinned(200, msg1, msg2)) + // An OLDER pin list arrives late — ignore it. + c.updatePinned(pinned(100, msg3)) + assertEquals(listOf(msg1, msg2), c.pinnedEventIds) + } + + @Test + fun equalCreatedAtPinDoesNotResupersede() { + val c = channel() + c.updatePinned(pinned(100, msg1, msg2)) + c.updatePinned(pinned(100, msg3)) + assertEquals(listOf(msg1, msg2), c.pinnedEventIds) + } + @Test fun placeholderNoteCarriesChannelGathererAndIsStable() { val c = channel() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt new file mode 100644 index 0000000000..a15aa5f589 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt @@ -0,0 +1,69 @@ +/* + * 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.nip29RelayGroups.metadata + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-29 relay-signed list of a group's pinned messages (kind 39005). The relay + * regenerates it from the accepted [com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent] + * (kind 9010) moderation actions, so this is the read side clients render — the + * source of truth for which messages are pinned and in what display order. + * + * Addressed by the group id (`d` tag). The pinned event ids are carried as `e` + * tags in display order. + */ +@Immutable +class GroupPinnedEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = dTag() + + /** Pinned message ids, in the relay's display order. */ + fun pinnedEventIds(): List = tags.mapValueTagged("e") { it } + + companion object { + const val KIND = 39005 + + fun build( + groupId: String, + pinnedEventIds: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag(groupId) + pinnedEventIds.forEach { add(arrayOf("e", it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt index 318fed79ac..32115a9b7c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt @@ -37,4 +37,6 @@ fun TagArray.userPubKeys(): List = mapNotNull(PTag::parseKey) fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it } +fun TagArray.pinnedEventIds(): List = mapValueTagged("e") { it } + fun TagArray.inviteCode() = firstNotNullOfOrNull(CodeTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt new file mode 100644 index 0000000000..9a53d7e7cc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt @@ -0,0 +1,64 @@ +/* + * 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.nip29RelayGroups.moderation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-29 `update-pin-list` moderation event (kind 9010). Carries the group `h` + * tag plus the FULL list of pinned message ids as `e` tags — pinning, unpinning, + * reordering and clearing pins are all done by submitting a new complete list. + * The relay checks the sender's role, applies it, and republishes the group's + * kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. + */ +@Immutable +class UpdatePinListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun groupId() = tags.groupId() + + fun pinnedEventIds() = tags.pinnedEventIds() + + companion object { + const val KIND = 9010 + + fun build( + groupId: String, + pinnedEventIds: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + groupId(groupId) + pinnedEventIds.forEach { add(arrayOf("e", it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 4957e42c81..fc216e3ae0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -103,6 +103,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent @@ -111,6 +112,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent @@ -401,6 +403,8 @@ class EventFactory { GroupMembersEvent.KIND -> GroupMembersEvent(id, pubKey, createdAt, tags, content, sig) SupportedRolesEvent.KIND -> SupportedRolesEvent(id, pubKey, createdAt, tags, content, sig) GroupParticipantsEvent.KIND -> GroupParticipantsEvent(id, pubKey, createdAt, tags, content, sig) + GroupPinnedEvent.KIND -> GroupPinnedEvent(id, pubKey, createdAt, tags, content, sig) + UpdatePinListEvent.KIND -> UpdatePinListEvent(id, pubKey, createdAt, tags, content, sig) ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig) RelayFeedsListEvent.KIND -> RelayFeedsListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt new file mode 100644 index 0000000000..3cdd3fbe2d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt @@ -0,0 +1,83 @@ +/* + * 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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * NIP-29 message-pinning wire format (PR #2379): the kind-9010 `update-pin-list` + * moderation write and the relay-signed kind-39005 pinned-list read side. Verifies + * both are dispatched to the right class by [EventFactory] and that the `h`/`d` group + * scope plus the ordered `e` id list round-trip through build → parse. + */ +class PinEventsTest { + private val relaySelf = "aa".repeat(32) + private val gid = "0123456789abcdef" + private val id1 = "11".repeat(32) + private val id2 = "22".repeat(32) + private val id3 = "33".repeat(32) + + @Test + fun groupPinnedEventParsesGroupAndOrderedIds() { + val tags = arrayOf(arrayOf("d", gid), arrayOf("e", id1), arrayOf("e", id2), arrayOf("e", id3)) + val event: Event = EventFactory.create("00".repeat(32), relaySelf, 100, GroupPinnedEvent.KIND, tags, "", "22".repeat(64)) + + assertEquals(true, event is GroupPinnedEvent) + event as GroupPinnedEvent + assertEquals(gid, event.groupId()) + assertEquals(listOf(id1, id2, id3), event.pinnedEventIds()) + } + + @Test + fun updatePinListEventParsesGroupAndIds() { + val tags = arrayOf(arrayOf("h", gid), arrayOf("e", id1), arrayOf("e", id2)) + val event: Event = EventFactory.create("00".repeat(32), relaySelf, 100, UpdatePinListEvent.KIND, tags, "", "22".repeat(64)) + + assertEquals(true, event is UpdatePinListEvent) + event as UpdatePinListEvent + assertEquals(gid, event.groupId()) + assertEquals(listOf(id1, id2), event.pinnedEventIds()) + } + + @Test + fun updatePinListBuildCarriesHTagAndFullList() { + val template = UpdatePinListEvent.build(gid, listOf(id1, id2)) + + assertEquals(UpdatePinListEvent.KIND, template.kind) + assertEquals(gid, template.tags.firstOrNull { it[0] == GroupIdTag.TAG_NAME }?.getOrNull(1)) + assertEquals(listOf(id1, id2), template.tags.filter { it[0] == "e" }.map { it[1] }) + } + + @Test + fun groupPinnedBuildCarriesDTagAndFullList() { + val template = GroupPinnedEvent.build(gid, listOf(id1, id2, id3)) + + assertEquals(GroupPinnedEvent.KIND, template.kind) + assertEquals(gid, template.tags.firstOrNull { it[0] == "d" }?.getOrNull(1)) + assertEquals(listOf(id1, id2, id3), template.tags.filter { it[0] == "e" }.map { it[1] }) + } +} From 65d16d185b29aa429f78c67c06480bd279efcee8 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:25:50 +0000 Subject: [PATCH 07/45] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-pl-rPL/strings.xml | 94 +++++++++++++++++++ docs/changelog/translators.json | 12 +-- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index ef3f34633a..2e7966e17b 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -103,6 +103,7 @@ Filtry bezpieczeństwa Wyloguj się Pokaż Więcej + Pokaż mniej Faktura LN Wygasła Oferta CLINK @@ -2778,6 +2779,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Usuń lokalizację Dodaj ostrzeżenie o treści Usuń ostrzeżenie o treści + %1$s utworzył kanał %2$s + %1$s utworzył kanał + %1$s zaktualizował profil kanału + Dostarczenie wiadomości + Zamknij + Oczekiwanie na akceptację tej wiadomości przez transmiter + Brak informacji o transmiterze tej wiadomości + Akceptowane przez co najmniej jeden transmiter + Dostarczono do transmiterów wszystkich odbiorców Dodaj datę wygaśnięcia Usuń datę wygaśnięcia Data wygaśnięcia @@ -3114,6 +3124,83 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Ostatnie 7 dni Komórka dzisiaj Wi-Fi dzisiaj + Czas transmitera dzisiaj + W aplikacji dzisiaj + Dane dziennie + Internet mobilny (MB) + Wi-Fi (MB) + Aktywność (7 dni) + Dane mobilny (w tle) + Dane komórkowe (w aplikacji) + Dane Wi-Fi + Czas połączenia z transmiterem + \u2026 w tle na komórce + Przetwarzanie powiadomień (procesor w stanie czuwania) + Uruchomione zadania w tle + Ponowne połączenia transmiterów (nieudane) + Żądania sieci Web + Wybudzanie radiowe podczas pobierania (szacunkowo) + Aktywny czas transferu + Czas odtwarzania multimediów + Podpisy zweryfikowane + Czas wykorzystania procesora + Czas w aplikacji + Rozpoczęcie działania aplikacji + Wiadomości odszyfrowane + Zdalne podpisy + Wydobycie Proof-of work + Czas działania sieci Tor + Czas rozmów + Czas w pokojach audio + Wykrywanie lokalizacji + Zużycie baterii (w aplikacji/tle) + Usługa powiadomień w tle + Utrzymuje otwarte połączenia z transmiterami nawet wtedy, gdy aplikacja jest zamknięta, dzięki czemu powiadomienia docierają bez pośrednictwa serwerów push firmy Google. Gdy ta funkcja jest włączona, zazwyczaj powoduje największe zużycie baterii w tle. Jeśli ważniejsza jest dla Ciebie oszczędność baterii niż natychmiastowe powiadomienia, możesz zmienić sposób dostarczania powiadomień lub wyłączyć tę funkcję. + Czas pracy + Połączenia transmiterów utrzymywane po zamknięciu + W międzyczasie rozładowała się bateria (całe urządzenie) + Zmień ustawienia powiadomień + Ustawienia prywatności + Bateria / godz. w aplikacji + Dane / godz. w aplikacji + Średnia liczba połączeń z transmiterem + Dane komórkowe wg funkcji (7 dni) + %1$s · %2$s łącznie + Czas wg ekranu (7 dni) + Wbudowany Tor + W celu zapewnienia prywatności przekierowuje ruch aplikacji przez sieć Tor. Podczas działania aplikacja zużywa więcej energii baterii na szyfrowanie i utrzymanie połączenia, a przy każdym uruchomieniu następuje bootstrap. Jeśli pozwala na to model ryzyka, korzystanie z sieci Tor można dostosować lub wyłączyć. + Dane wg funkcji (7 dni) + Synchronizacja Transmiterów + Zdjęcia + Audio & wideo + Wgrane + Portfel & zaps + Weryfikacja adresu + Podgląd linku + Rejestracja Push + Inne + Nie odnotowano jeszcze żadnego użycia. + Pamięć teraz + Pamięć aplikacji (heap) + Pamięć rodzima + Pamięć podręczna (RAM) obrazu + Image cache (disk) + Notatki w pamięci + Profile w pamięci + Adresowalne wydarzenia w pamięci + Listy czatów w pamięci + Klasa pamięci urządzenia + Udostępnij deweloperom + Jeśli zauważysz, że aplikacja Amethyst nadmiernie zużywa baterię lub transfer danych, możesz przesłać ten raport do twórców w zaszyfrowanej wiadomości prywatnej. Zawiera on wyłącznie liczby widoczne na tym ekranie oraz związane z nimi liczniki techniczne \u2014 nie zawiera żadnych postów, kontaktów ani szczegółów dotyczących przeglądania stron. Żadne dane nie zostaną wysłane, dopóki nie klikniesz przycisku „Wyślij” na ekranie wiadomości. + Wyślij raport za pośrednictwem DM + Wykryto wysokie użycie zasobów + W ostatnim czasie Amethyst zużył więcej zasobów, niż oczekiwano: %1$s. Czy chcesz wysłać raport dotyczący zużycia do twórców w zaszyfrowanej wiadomości prywatnej? Przed wysłaniem zobaczysz pełną treść raportu. + %1$s danych komórkowych w tle w ciągu jednego dnia + %1$s połączeń z transmiterem w ciągu jednego dnia, gdy aplikacja działała w tle przy połączeniu komórkowym + procesor pozostawał w stanie czuwania przez %1$s w celu przetwarzania powiadomień w ciągu jednego dnia + Przejrzyj & wyślij + Nie teraz + Nie pytaj ponownie Ta wiadomość zniknie za %1$s Wybierz Sygnatariusza Już jest na liście @@ -3478,6 +3565,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Odtwarzanie Automatycznie + Edytuj pseudonim + Widoczne tylko dla Ciebie Zrzuć na urządzenie Przestań zrzucać @@ -3706,6 +3795,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Dodawane na końcu wiadomości podczas tworzenia nowego wpisu, odpowiedzi, cytatu lub artykułu. Aby wyłączyć tę funkcję, pozostaw to pole puste. Twój podpis Dowód wykonania + Autor wykorzystał moc obliczeniową, aby wygenerować tę notatkę przy poziomie trudności %1$s — jest to pieczęć typu „proof-of-work”, która sprawia, że spamowanie staje się kosztowne. Stopień trudności Przed opublikowaniem postów generuje się w nich dowód wykonania pracy zgodny ze standardem NIP-13, dzięki czemu transmitery i czytelnicy mogą je zweryfikować pod kątem spamu. Im wyższa wartość, tym wydobycie trwa wykładniczo dłużej. Po wygenerowaniu posty są publikowane w tle. Wył. @@ -3899,6 +3989,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Wpisz nazwę grupy Dodaj opis grupy (opcjonalnie) Zmiany zostaną zatwierdzone w grupie za pośrednictwem MLS i przekazane wszystkim członkom. + Ikona grupy + Dodaj zdjęcie + Zmień zdjęcie + Usuń zdjęcie Informacje o grupie zaktualizowane Nie udało się zaktualizować: %1$s Nie udało się utworzyć grupy: %1$s diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 8419386004..b56145cbb7 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -122,18 +122,18 @@ "Vietnamese" ] }, - { - "user": "rajs19420616", - "languages": [ - "Hindi" - ] - }, { "user": "maxblake2015", "languages": [ "Polish" ] }, + { + "user": "rajs19420616", + "languages": [ + "Hindi" + ] + }, { "user": "summoner001", "languages": [ From 1896d77bd921119c44200b2eb9e0d45573148d08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:40:54 +0000 Subject: [PATCH 08/45] refactor(desktop): drop the global blossomServers pref, read per-account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind-10063 list is already the per-account source of truth in the cache, so the DesktopPreferences.blossomServers singleton was redundant — and being process-global (not per-account) it was also a latent account-switch bug: the mirror could hand one account's media servers to another. Remove it and have every consumer read the account's list from the cache: - Add ICacheProvider.blossomServers(pubKey) / preferredBlossomServer(pubKey) helpers (+ DEFAULT_BLOSSOM_SERVER fallback). - Upload paths read per-account: ComposeNoteDialog (localCache), ChatPane (its cacheProvider), EditProfileDialog (localCache threaded from UserProfileScreen). - Settings screen falls back to the default constant instead of the pref; drop the flow→prefs mirror LaunchedEffect. - Delete DesktopPreferences.blossomServers / preferredBlossomServer. Uploads require network anyway, by which point the account-config subscription has loaded kind 10063, so no local persistence is needed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2 --- .../amethyst/desktop/DesktopPreferences.kt | 13 ------ .../vitorpamplona/amethyst/desktop/Main.kt | 22 +++------- .../amethyst/desktop/model/BlossomServers.kt | 42 +++++++++++++++++++ .../amethyst/desktop/ui/ComposeNoteDialog.kt | 14 +++++-- .../amethyst/desktop/ui/UserProfileScreen.kt | 1 + .../amethyst/desktop/ui/chats/ChatPane.kt | 4 +- .../desktop/ui/deck/DeckColumnContainer.kt | 4 +- .../desktop/ui/profile/EditProfileScreen.kt | 6 ++- .../cache/DesktopBlossomServerListTest.kt | 21 ++++++++++ 9 files changed, 87 insertions(+), 40 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index df3bb92416..cd96d371e6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -93,19 +93,6 @@ object DesktopPreferences { prefs.put(KEY_PINNED_NAV_ITEMS, value) } - private const val KEY_BLOSSOM_SERVERS = "blossom_servers" - private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" - - var blossomServers: List - get() { - val raw = prefs.get(KEY_BLOSSOM_SERVERS, DEFAULT_BLOSSOM_SERVER) - return if (raw.isBlank()) emptyList() else raw.split(",") - } - set(value) = prefs.put(KEY_BLOSSOM_SERVERS, value.joinToString(",")) - - val preferredBlossomServer: String - get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER - private const val KEY_SIDEBAR_COLLAPSED = "sidebar_collapsed" var sidebarCollapsed: Boolean diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index d4c9671a2d..35692db5fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.auth.DesktopAuthCoordinator import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories @@ -1721,19 +1722,6 @@ fun MainContent( onDispose { relayManager.unsubscribe(bootstrapSubId) } } - // Mirror the network Blossom server list (kind 10063) into local prefs so - // the upload path (ComposeNoteDialog) and cold start reflect the list the - // user configured on any Amethyst client. Only overwrite with a non-empty - // network list — an empty flow value means the event hasn't loaded yet, and - // clobbering prefs then would wipe the user's offline fallback. - LaunchedEffect(iAccount) { - iAccount.blossomServerList.flow.collect { servers -> - if (servers.isNotEmpty() && servers != DesktopPreferences.blossomServers) { - DesktopPreferences.blossomServers = servers - } - } - } - // Subscribe to incoming DMs and process into chatroomList LaunchedEffect(account) { relayManager.connectedRelays.first { it.isNotEmpty() } @@ -2237,7 +2225,7 @@ fun RelaySettingsScreen( onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {}, namecoinPreferences: DesktopNamecoinPreferences? = null, blossomServers: kotlinx.coroutines.flow.StateFlow>? = null, - onBlossomServersChanged: (List) -> Unit = { DesktopPreferences.blossomServers = it }, + onBlossomServersChanged: (List) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -2363,9 +2351,9 @@ fun RelaySettingsScreen( // Media Server Settings (Blossom, kind 10063 — synced with mobile) val networkBlossomServers by (blossomServers?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) - // The kind-10063 list is authoritative when present; before it loads - // (or when the user has none) fall back to the local prefs mirror. - val effectiveBlossomServers = networkBlossomServers.ifEmpty { DesktopPreferences.blossomServers } + // The kind-10063 list is authoritative; before it loads (or when the + // user has published none) show the default server. + val effectiveBlossomServers = networkBlossomServers.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } key(effectiveBlossomServers) { MediaServerSettings( initialServers = effectiveBlossomServers, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt new file mode 100644 index 0000000000..348ef66cff --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt @@ -0,0 +1,42 @@ +/* + * 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.desktop.model + +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent + +/** Fallback media server used when the account has published no kind-10063 list yet. */ +const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" + +/** + * The account's Blossom media servers (NIP-B7 / kind 10063), read straight from + * the cache. This is the per-account source of truth — the same event the + * account-config subscription loads and the mobile app uses — so upload sites + * read it here instead of a process-global preference. + */ +fun ICacheProvider.blossomServers(pubKeyHex: HexKey): List = + (getOrCreateAddressableNote(BlossomServersEvent.createAddress(pubKeyHex)).event as? BlossomServersEvent) + ?.servers() + .orEmpty() + +/** First declared Blossom server for [pubKeyHex], or [DEFAULT_BLOSSOM_SERVER] when none is set. */ +fun ICacheProvider.preferredBlossomServer(pubKeyHex: HexKey): String = blossomServers(pubKeyHex).firstOrNull() ?: DEFAULT_BLOSSOM_SERVER diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index b98cbada5c..4c38c8f4fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -74,10 +74,11 @@ import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadResult import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.util.deleteOrWarn -import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.ImageCompressionStore import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.model.blossomServers import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.drafts.LocalNoteDraftStore import com.vitorpamplona.amethyst.desktop.service.drafts.NoteDraft @@ -205,7 +206,13 @@ fun ComposeNoteDialog( val uploadTracker = remember { DesktopUploadTracker() } val uploadState by uploadTracker.state.collectAsState() val orchestrator = remember { UploadOrchestrator() } - var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) } + // Media servers come from the account's kind-10063 list (per-account, loaded + // by the account-config subscription), not a process-global preference. + val blossomServers = + remember(localCache, account) { + localCache?.blossomServers(account.pubKeyHex).orEmpty().ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } + } + var selectedServer by remember { mutableStateOf(blossomServers.first()) } var postAsPicture by remember { mutableStateOf(false) } // Scheduling — when non-null the note is stored for later publication instead of @@ -576,6 +583,7 @@ fun ComposeNoteDialog( verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { ServerSelector( + servers = blossomServers, selectedServer = selectedServer, onServerSelected = { selectedServer = it }, ) @@ -826,10 +834,10 @@ private fun buildIMetaTags(results: List): List = @Composable private fun ServerSelector( + servers: List, selectedServer: String, onServerSelected: (String) -> Unit, ) { - val servers = DesktopPreferences.blossomServers if (servers.size <= 1) { // Only one server — just show label, no dropdown Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 8540b85045..2ccec9a7c9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -1189,6 +1189,7 @@ fun UserProfileScreen( EditProfileDialog( account = account, relayManager = relayManager, + localCache = localCache, latestMetadata = latestMetadataEvent, latestIdentities = latestIdentitiesEvent, onDismiss = { showEditProfile = false }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index c746d64487..08ace28d94 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -88,7 +88,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel -import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow @@ -882,7 +882,7 @@ private suspend fun sendEncryptedFiles( cacheProvider: ICacheProvider, ) { val orchestrator = UploadOrchestrator() - val server = DesktopPreferences.preferredBlossomServer + val server = cacheProvider.preferredBlossomServer(account.pubKey) val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) }.map { it.toPTag() } for (file in files) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 7a4f369dd4..ac1d8b27fd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -504,12 +504,10 @@ internal fun RootContent( namecoinPreferences = LocalNamecoinPreferences.current, blossomServers = iAccount.blossomServerList.flow, onBlossomServersChanged = { servers -> - // Local mirror for the upload path + cold start. - com.vitorpamplona.amethyst.desktop.DesktopPreferences.blossomServers = servers // Publish a kind-10063 event so the list syncs to every // Amethyst client, then consume it locally so state updates // immediately (mirrors the NIP-65 save flow above). Read-only - // accounts can't sign, so keep the change local-only there. + // accounts can't sign, so this is a no-op there. if (iAccount.isWriteable()) { appScope.launch { val event = iAccount.blossomServerList.saveBlossomServersList(servers) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index 883b01733a..5aba2eb7f4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -79,8 +79,9 @@ import com.vitorpamplona.amethyst.commons.profile.EditProfileFields import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator -import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -124,6 +125,7 @@ sealed class Nip05Status { fun EditProfileDialog( account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, latestMetadata: MetadataEvent?, latestIdentities: ExternalIdentitiesEvent?, onDismiss: () -> Unit, @@ -171,7 +173,7 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = DesktopPreferences.preferredBlossomServer + val serverBaseUrl = localCache.preferredBlossomServer(account.pubKeyHex) fun uploadFile( file: File, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt index 6cdb574b23..fbdb45791d 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.desktop.cache import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER +import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -96,4 +98,23 @@ class DesktopBlossomServerListTest { assertEquals(servers, state.getBlossomServersList()?.servers()) assertEquals(servers, state.flow.first { it.isNotEmpty() }) } + + @Test + fun `preferredBlossomServer reads the account's first server from cache`() = + runTest { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + val servers = listOf("https://first.example.com", "https://second.example.com") + cache.consume(signedServerList(servers, signer), relayUrl) + + assertEquals("https://first.example.com", cache.preferredBlossomServer(signer.pubKey)) + } + + @Test + fun `preferredBlossomServer falls back to the default when the account has no list`() { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + + assertEquals(DEFAULT_BLOSSOM_SERVER, cache.preferredBlossomServer(signer.pubKey)) + } } From b8966049b82d39411827f708a34df40250779a6d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:59:25 +0000 Subject: [PATCH 09/45] fix: Concord channel rows stuck on "No messages yet" despite having messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit consumeConcordRumor attaches a message row to its ConcordChannel BEFORE justConsume loads the event (so the note carries its gatherer through the Messages filter). That means Channel.addNote ran while createdAt() was still null and never set lastNote — and on reprojects the containsKey guard skips addNote entirely, so lastNote stayed null forever for every Concord channel. The list/hub rows read lastNote, so they always showed "No messages yet" (and activity sorting was a no-op) even after messages had loaded. Adds Channel.refreshAfterEventLoad(note), called right after justConsume once the event is present: it picks lastNote when newer and invalidates the notes flow so previews, unread counts, and ordering recompute. Regression-tested in commons (ConcordChannelLastNoteTest). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e --- .../amethyst/model/LocalCache.kt | 10 +- .../amethyst/commons/model/Channel.kt | 18 ++++ .../concord/ConcordChannelLastNoteTest.kt | 93 +++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelLastNoteTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index c7914c418c..40bc4ba53d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -790,7 +790,15 @@ object LocalCache : ILocalCache, ICacheProvider { // permanent "Event is loading…" ghost. Drop it; the reverse order (delete after the message) // is already handled by the normal deletion cascade unlinking the note from its gatherers. messageRow?.let { (ch, note) -> - if (note.event == null) ch.removeNote(note) + if (note.event == null) { + ch.removeNote(note) + } else { + // The row was attached (addNote) BEFORE justConsume set the event, so addNote saw a + // null createdAt and could not pick lastNote or order the feed. The event is loaded + // now — refresh so the channel's last-message preview, unread count, and ordering are + // correct (otherwise lastNote stays null forever and every row reads "No messages yet"). + ch.refreshAfterEventLoad(note) + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt index 960d123b87..061991deeb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt @@ -116,6 +116,24 @@ abstract class Channel : NotesGatherer { } } + /** + * Refresh derived state after an already-[addNote]'d [note] finishes loading its event. + * + * Most channels attach a note whose event is already set, so [addNote] can pick [lastNote] and + * order the feed right away. Concord is the exception: it attaches a message row to its channel + * *before* the rumor is consumed (so the row already carries its gatherer when it flows through + * the Messages filter), which means [addNote] ran while `createdAt()` was still null and could + * neither set [lastNote] nor sort correctly. Once the event lands, call this so [lastNote] and + * any notes-flow observers (row previews, unread counts, ordering) reflect the real message. + */ + fun refreshAfterEventLoad(note: Note) { + if (!notes.containsKey(note.idHex)) return + if ((note.createdAt() ?: 0L) > (lastNote?.createdAt() ?: 0L)) { + lastNote = note + } + flowSet?.notes?.invalidateData() + } + override fun removeNote(note: Note) { if (notes.containsKey(note.idHex)) { notes.remove(note.idHex) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelLastNoteTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelLastNoteTest.kt new file mode 100644 index 0000000000..3505bc2fa9 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelLastNoteTest.kt @@ -0,0 +1,93 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Regression cover for the Concord attach-before-consume ordering: `LocalCache.consumeConcordRumor` + * attaches a message row to its [ConcordChannel] (so the note carries its gatherer through the + * Messages filter) *before* `justConsume` loads the event, so [com.vitorpamplona.amethyst.commons.model.Channel.addNote] + * runs while `createdAt()` is null and cannot pick [com.vitorpamplona.amethyst.commons.model.Channel.lastNote]. + * Without the [com.vitorpamplona.amethyst.commons.model.Channel.refreshAfterEventLoad] follow-up, + * `lastNote` stays null forever and every channel row renders "No messages yet" even after messages + * have loaded. + */ +class ConcordChannelLastNoteTest { + private val community = "aa".repeat(32) + private val channelId = "cc".repeat(32) + private val author = "bb".repeat(32) + + private fun channel() = ConcordChannel(ConcordChannelId(community, channelId)) + + private fun event(createdAt: Long): Event = EventFactory.create("00".repeat(32), author, createdAt, 1, arrayOf(), "hi", "22".repeat(64)) + + @Test + fun lastNoteStaysNullUntilEventLoads_thenReflectsIt() { + val c = channel() + val note = Note("11".repeat(32)) + + // Attach exactly as Concord does — before the rumor is consumed, so the event is still null. + c.addNote(note) + assertNull(c.lastNote, "addNote can't pick lastNote while createdAt() is null (the bug)") + + // justConsume loads the event; the follow-up refresh must now set lastNote. + note.event = event(1000) + c.refreshAfterEventLoad(note) + assertEquals(note, c.lastNote) + } + + @Test + fun refreshKeepsTheNewestAndIgnoresLaterOlderLoads() { + val c = channel() + val older = Note("11".repeat(32)) + val newer = Note("22".repeat(32)) + c.addNote(older) + c.addNote(newer) + + older.event = event(1000) + c.refreshAfterEventLoad(older) + newer.event = event(2000) + c.refreshAfterEventLoad(newer) + assertEquals(newer, c.lastNote) + + // An out-of-order older message loading afterwards must not overwrite the newest. + val evenOlder = Note("33".repeat(32)) + c.addNote(evenOlder) + evenOlder.event = event(500) + c.refreshAfterEventLoad(evenOlder) + assertEquals(newer, c.lastNote) + } + + @Test + fun refreshIsANoOpForANoteNotInThisChannel() { + val c = channel() + val stray = Note("44".repeat(32)).apply { event = event(9999) } + c.refreshAfterEventLoad(stray) + assertNull(c.lastNote) + } +} From 18f1d8749274dfbdc8aedf8e36b6bd0dd23c8bc4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 15 Jul 2026 18:24:45 -0400 Subject: [PATCH 10/45] fix: Concord unread badge + last-message previews ignore thread replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unread count, the channel-list/hub last-message previews, and the Messages-row summary all read a broader note set than the channel feed renders: the feed's ChannelFeedFilter hides kind-1111 CommentEvent thread replies (and unacceptable authors), but these row surfaces counted/showed them. A trailing minichat reply therefore stuck the unread badge at a count opening the channel could never clear (markAsRead is monotonic and only advances for rendered timeline messages), and showed up as a "last message" that isn't on the timeline. Centralizes the feed's predicate as isConcordTimelineMessage (loaded, acceptable, not a CommentEvent) plus a newestTimelineNote helper, and routes the unread count, both list-row previews, and the Messages hub filter through it — so every channel-row summary agrees with what the open channel renders. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/ConcordChannelListScreen.kt | 4 +- .../concord/ConcordHomeScreen.kt | 4 +- .../publicChannels/concord/ConcordUnread.kt | 45 +++++++++++++++++-- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 10 ++++- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index c68fcb05fb..3aebe57917 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -338,7 +338,9 @@ private fun ConcordChannelListRow( .flow() .notes.stateFlow .collectAsStateWithLifecycle() - val lastNote = channelState.channel.lastNote + // The newest *timeline* message (not the raw lastNote): skips kind-1111 thread replies and + // hidden authors so the preview + time match the channel feed and the unread badge below. + val lastNote = remember(channelState) { channel.newestTimelineNote(account) } val unread by remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) } .collectAsStateWithLifecycle(0) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 34def3f473..6211adcf9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -402,7 +402,9 @@ private fun ConcordChannelRow( .flow() .notes.stateFlow .collectAsStateWithLifecycle() - val lastNote = channelState.channel.lastNote + // The newest *timeline* message (not the raw lastNote): skips kind-1111 thread replies and + // hidden authors so the preview + time match the channel feed and the unread badge below. + val lastNote = remember(channelState) { channel.newestTimelineNote(account) } val unreadCount by remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) } .collectAsStateWithLifecycle(0) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt index a453cc031f..f7590d71c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt @@ -20,11 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -51,12 +54,48 @@ fun concordChannelUnreadCountFlow( account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)), channel.flow().notes.stateFlow, ) { lastRead, _ -> - channel.newMessagesSince(lastRead) + channel.newMessagesSince(account, lastRead) } } -/** The number of this channel's messages created strictly after [sinceSecs] (0 if none). */ -private fun ConcordChannel.newMessagesSince(sinceSecs: Long): Int = notes.count { _, note -> (note.createdAt() ?: 0L) > sinceSecs } +/** + * True for a note the Concord channel *timeline* actually renders — the same predicate as + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedFilter]'s + * `isTimelineMessage`: a loaded, acceptable message that is **not** a kind-1111 [CommentEvent]. + * + * A [CommentEvent] is a *minichat thread reply* that lives inside its parent's thread, not on the + * flat timeline, so it never composes on the channel screen and never advances the last-read + * marker. Every list-row surface that summarizes a channel — the unread badge + * ([newMessagesSince]), the last-message preview + timestamp ([newestTimelineNote]), and the + * Messages hub row — reuses this so none of them can disagree with the open channel's feed: + * a trailing comment can't stick the badge at a count the user can never clear, nor show up as a + * "last message" that isn't in the timeline. Unacceptable (muted/blocked) authors are hidden for + * the same reason. + */ +fun isConcordTimelineMessage( + note: Note, + account: Account, +): Boolean = note.event.let { it != null && it !is CommentEvent } && account.isAcceptable(note) + +/** + * The newest timeline message in this channel (see [isConcordTimelineMessage]), or null if none — + * the note the list/hub rows show as the channel's "last message". Unlike [ConcordChannel.lastNote] + * (the raw newest note of any kind), this skips thread replies and hidden authors so the preview + * matches what the channel feed renders and the unread badge counts. + */ +fun ConcordChannel.newestTimelineNote(account: Account): Note? = + notes + .filter { _, note -> isConcordTimelineMessage(note, account) } + .minWithOrNull(Channel.DefaultFeedOrder) + +/** The number of this channel's timeline messages created strictly after [sinceSecs] (0 if none). */ +private fun ConcordChannel.newMessagesSince( + account: Account, + sinceSecs: Long, +): Int = + notes.count { _, note -> + (note.createdAt() ?: 0L) > sinceSecs && isConcordTimelineMessage(note, account) + } /** * The pubkeys of the [limit] most-recent distinct posters in this channel, newest first — the 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 c6def6253c..d99770ba6d 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,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.isConcordTimelineMessage import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId @@ -400,10 +401,15 @@ class ChatroomListKnownFeedFilter( .sortedByDefaultFeedOrder() .firstOrNull() - /** The newest decrypted message loaded in this Concord channel, or null if none yet. */ + /** + * The newest decrypted *timeline* message loaded in this Concord channel, or null if none yet. + * Uses [isConcordTimelineMessage] so a trailing kind-1111 thread reply (or a hidden author) + * isn't shown as the Messages-row "last message" — the same predicate the channel feed and the + * unread badge use, so the row summary can't disagree with what opening the channel renders. + */ private fun ConcordChannel.newestConcordNote(account: Account): Note? = notes - .filter { _, it -> account.isAcceptable(it) && it.event != null } + .filter { _, it -> isConcordTimelineMessage(it, account) } .sortedByDefaultFeedOrder() .firstOrNull() From ca01e98d6044e9e6ac50fc7ed1711e9286482c3d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:34:31 +0000 Subject: [PATCH 11/45] fix(chat): drop the dead footer gap on messages with no timestamp or chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chat bubble reserves ~8dp beneath its last line of text so the reaction chips, which overlap the bottom border, ride over padding instead of the text. That reserve was gated on `reactionsRow != null`, but the reaction row was passed for every non-inner-quote message regardless of whether it had any engagement — so every footerless bubble (the middle messages of an author run, which don't show the timestamp) reserved space for an empty chip row, leaving a visible gap between the text and the bubble's bottom edge. Gate the chip row on actual engagement via a new `hasChatEngagement` helper that mirrors the render gate in `RenderChatReactionChips` (reactions, zaps, pending zaps, minichat replies) and keeps the same live subscriptions, so a message that gains its first reaction still mounts the row. Footerless messages with no engagement now hug their text; the reserve returns only when chips are actually present. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017ghZ7egU86LT4Z5BBvyLvY --- .../loggedIn/chats/feed/ChatMessageCompose.kt | 8 +++- .../loggedIn/chats/feed/ChatReactionChips.kt | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 6f4ba72218..2528cae7ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -238,6 +238,12 @@ fun NormalChatNote( // carrying per-message metadata (expiration, geohash, PoW, legacy-DM marker). val footerHasMeta = remember(note.event) { chatFooterHasMeta(note) } + // Only mount the reaction/zap chip row when the message actually has engagement. + // The chips overlap the bubble's bottom edge, so the bubble reserves space beneath + // the last line of text for them — without this gate every footerless bubble would + // reserve that space for an empty row, leaving a dead gap under the text. + val hasEngagement = !innerQuote && hasChatEngagement(note, accountViewModel) + ChatBubbleLayout( isLoggedInUser = isLoggedInUser, isDraft = note.event is DraftWrapEvent, @@ -285,7 +291,7 @@ fun NormalChatNote( ) }, reactionsRow = - if (!innerQuote) { + if (hasEngagement) { { ChatReactionChips(note, accountViewModel, nav) } } else { null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt index 05dc77ecb8..1b68501bec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCorner import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -89,6 +90,43 @@ private data class ReactionChip( private val ChipGlyphFontSize = 13.sp +/** + * Whether [ChatReactionChips] would render anything for this message — a reaction, a + * zap, a pending zap, or a minichat-reply count. Mirrors the early-return gate in + * [RenderChatReactionChips] and keeps the same live subscriptions, so a message that + * gains its first reaction still flips this on. The bubble uses it to decide whether to + * even mount the chip row (and reserve overlap space beneath the text) instead of always + * paying for an empty row on every message. + */ +@Composable +fun hasChatEngagement( + baseNote: Note, + accountViewModel: AccountViewModel, +): Boolean { + val reactionsState by observeNoteReactions(baseNote, accountViewModel) + val hasReactions by + remember(reactionsState) { + derivedStateOf { (reactionsState?.note ?: baseNote).reactions.isNotEmpty() } + } + + val zapsState by observeNoteZaps(baseNote, accountViewModel) + val hasZaps by + remember(zapsState) { + derivedStateOf { (zapsState?.note ?: baseNote).zaps.isNotEmpty() } + } + + val supportsMinichat = + remember(baseNote) { + baseNote.inGatherers?.any { it is ConcordChannel || it is PublicChatChannel || it is RelayGroupChannel } == true + } + val minichatCount by if (supportsMinichat) observeNoteMinichatReplyCount(baseNote, accountViewModel) else remember { mutableStateOf(0) } + + val zapsInFlight by accountViewModel.zapsInFlightFlow.collectAsStateWithLifecycle() + val isZapping = zapsInFlight.contains(baseNote.idHex) + + return hasReactions || hasZaps || minichatCount > 0 || isZapping +} + /** * Messenger-style pills under a chat bubble summarizing who engaged with the message: * one chip per reaction emoji (with count, highlighted when the logged-in user is From 2eaaa270c3b738080d1bfcf15dd93eab07b82bdd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:51:57 +0000 Subject: [PATCH 12/45] refactor(desktop): read blossom servers from the account, not the cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ICacheProvider.blossomServers(pubKey) cache helper with reads straight from the account's own state holder — iAccount.blossomServerList.flow (the shared BlossomServerListState) — threaded to each upload site. This is the reactive, per-account source of truth and drops the cache+pubkey indirection entirely. - Hoist iAccount (with dmSendTracker + accountRelays) out of MainContent into the LoggedIn branch so the top-level compose dialog can read the account's blossom flow too; pass them into MainContent as params. - Thread iAccount.blossomServerList.flow into ComposeNoteDialog (reactive: the server picker updates if the list loads after the dialog opens), EditProfileDialog (via UserProfileScreen), and ChatPane (via DesktopMessagesScreen). - Reduce BlossomServers.kt to just the DEFAULT_BLOSSOM_SERVER fallback used when the account has published no kind-10063 list yet. Known gap: quote-compose opened from a feed row (NoteActionsRow) still defaults to DEFAULT_BLOSSOM_SERVER — the per-note card composables don't carry the account handle, and threading it through the whole note-render tree isn't worth it for that secondary path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2 --- .../vitorpamplona/amethyst/desktop/Main.kt | 51 ++++++++++--------- .../amethyst/desktop/model/BlossomServers.kt | 23 ++------- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 22 ++++---- .../amethyst/desktop/ui/FeedScreen.kt | 1 + .../amethyst/desktop/ui/NoteActions.kt | 2 + .../amethyst/desktop/ui/UserProfileScreen.kt | 3 +- .../amethyst/desktop/ui/chats/ChatPane.kt | 8 ++- .../desktop/ui/chats/DesktopMessagesScreen.kt | 2 + .../desktop/ui/deck/DeckColumnContainer.kt | 3 ++ .../desktop/ui/profile/EditProfileScreen.kt | 8 +-- .../cache/DesktopBlossomServerListTest.kt | 21 -------- 11 files changed, 64 insertions(+), 80 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 35692db5fe..b31735110f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1341,6 +1341,25 @@ private fun AppInner( val account = accountState as AccountState.LoggedIn val nwcConnection by accountManager.nwcConnection.collectAsState() + // Account state holders (relay lists, blossom servers, DMs, WoT). + // Hoisted above MainContent so the top-level compose dialog can also + // read the account's blossom server list from iAccount directly. + val dmSendTracker = remember(relayManager) { DmSendTracker(relayManager.client) } + // Created before iAccount so NIP-65 backup can be loaded. + val accountRelays = + remember(account, relayManager, scope) { + DesktopAccountRelays(account.pubKeyHex, relayManager, scope) + } + val iAccount = + remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) { + DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver) + } + // When iAccount is replaced (account switch), close the previous + // WoTService so its writer coroutine + ops Channel don't leak. + DisposableEffect(iAccount) { + onDispose { iAccount.wotService.close() } + } + // Lazy-load Namecoin services. The Core RPC HTTP // client is sourced from the Tor-aware DesktopHttpClient // singleton so .onion RPC URLs route through the @@ -1414,6 +1433,9 @@ private fun AppInner( localCache = localCache, accountManager = accountManager, account = account, + iAccount = iAccount, + accountRelays = accountRelays, + dmSendTracker = dmSendTracker, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, indexRelaysStore = indexRelaysStore, @@ -1456,6 +1478,7 @@ private fun AppInner( relayManager = relayManager, account = account, localCache = localCache, + blossomServers = iAccount.blossomServerList.flow, replyTo = replyToNote, draftDTag = composeEditDraftTag, draftInitialContent = composeEditContent, @@ -1544,6 +1567,9 @@ fun MainContent( localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + iAccount: DesktopIAccount, + accountRelays: DesktopAccountRelays, + dmSendTracker: DmSendTracker, nwcConnection: Nip47WalletConnect.Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, indexRelaysStore: com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays, @@ -1563,31 +1589,6 @@ fun MainContent( val signerConnectionState by accountManager.signerConnectionState.collectAsState() val lastPingTimeSec by accountManager.lastPingTimeSec.collectAsState() - // DM infrastructure — hoisted here so it survives screen navigation - val dmSendTracker = - remember(relayManager) { - DmSendTracker(relayManager.client) - } - // Centralized relay state for all categories (DM, search, blocked, NIP-65 persistence) - // Created before iAccount so NIP-65 backup can be loaded - val accountRelays = - remember(account, relayManager, scope) { - DesktopAccountRelays(account.pubKeyHex, relayManager, scope) - } - - val iAccount = - remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) { - DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver) - } - - // When iAccount is replaced (account switch), the previous WoTService's - // internal writer coroutine + ops Channel would otherwise leak — the - // outer `scope` lives for the whole session. Close the previous - // instance on dispose so account-switch is a clean teardown. - DisposableEffect(iAccount) { - onDispose { iAccount.wotService.close() } - } - // Follow Packs state — single per-account holder for Discover + sidebar + naddr cards val followPacksState = remember(iAccount, localCache, relayManager, scope) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt index 348ef66cff..d3a319be66 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt @@ -20,23 +20,10 @@ */ package com.vitorpamplona.amethyst.desktop.model -import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent - -/** Fallback media server used when the account has published no kind-10063 list yet. */ -const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" - /** - * The account's Blossom media servers (NIP-B7 / kind 10063), read straight from - * the cache. This is the per-account source of truth — the same event the - * account-config subscription loads and the mobile app uses — so upload sites - * read it here instead of a process-global preference. + * Fallback media server used when the account has published no kind-10063 + * Blossom server list yet. The list itself is read reactively from the account's + * [com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState] + * (`iAccount.blossomServerList.flow`); this is only the empty-list default. */ -fun ICacheProvider.blossomServers(pubKeyHex: HexKey): List = - (getOrCreateAddressableNote(BlossomServersEvent.createAddress(pubKeyHex)).event as? BlossomServersEvent) - ?.servers() - .orEmpty() - -/** First declared Blossom server for [pubKeyHex], or [DEFAULT_BLOSSOM_SERVER] when none is set. */ -fun ICacheProvider.preferredBlossomServer(pubKeyHex: HexKey): String = blossomServers(pubKeyHex).firstOrNull() ?: DEFAULT_BLOSSOM_SERVER +const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 4c38c8f4fe..189437556e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.desktop.ImageCompressionStore import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount -import com.vitorpamplona.amethyst.desktop.model.blossomServers import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.drafts.LocalNoteDraftStore import com.vitorpamplona.amethyst.desktop.service.drafts.NoteDraft @@ -119,6 +118,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.datatransfer.DataFlavor @@ -140,6 +140,7 @@ fun ComposeNoteDialog( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, localCache: com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache? = null, + blossomServers: StateFlow>? = null, replyTo: Event? = null, quoteOf: Event? = null, draftDTag: String? = null, @@ -206,13 +207,16 @@ fun ComposeNoteDialog( val uploadTracker = remember { DesktopUploadTracker() } val uploadState by uploadTracker.state.collectAsState() val orchestrator = remember { UploadOrchestrator() } - // Media servers come from the account's kind-10063 list (per-account, loaded - // by the account-config subscription), not a process-global preference. - val blossomServers = - remember(localCache, account) { - localCache?.blossomServers(account.pubKeyHex).orEmpty().ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } - } - var selectedServer by remember { mutableStateOf(blossomServers.first()) } + // Media servers come straight from the account's kind-10063 list holder + // (account.blossomServerList.flow), reactively — no cache-poking, no prefs. + val serverList by (blossomServers?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) + val effectiveServers = serverList.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } + var selectedServer by remember { mutableStateOf(effectiveServers.first()) } + // If the list loads (or changes) after the dialog opens and the current pick + // is no longer in it, snap to the first available server. + LaunchedEffect(effectiveServers) { + if (selectedServer !in effectiveServers) selectedServer = effectiveServers.first() + } var postAsPicture by remember { mutableStateOf(false) } // Scheduling — when non-null the note is stored for later publication instead of @@ -583,7 +587,7 @@ fun ComposeNoteDialog( verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { ServerSelector( - servers = blossomServers, + servers = effectiveServers, selectedServer = selectedServer, onServerSelected = { selectedServer = it }, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 1eace52ec5..096bf03a59 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -1043,6 +1043,7 @@ fun FeedScreen( relayManager = relayManager, account = account, localCache = localCache, + blossomServers = iAccount?.blossomServerList?.flow, replyTo = replyToEvent, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 7a52fb1d08..1c51828dd5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -1376,6 +1376,8 @@ fun NoteActionsRow( relayManager = relayManager, account = account, localCache = localCache, + // Quote-compose from a feed row falls back to the default media server: + // the per-note card composables don't carry the account's blossom flow. quoteOf = quoteEvent, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 2ccec9a7c9..50274077bb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -122,6 +122,7 @@ fun UserProfileScreen( relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn?, + blossomServers: kotlinx.coroutines.flow.StateFlow>? = null, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, @@ -1189,7 +1190,7 @@ fun UserProfileScreen( EditProfileDialog( account = account, relayManager = relayManager, - localCache = localCache, + blossomServers = blossomServers, latestMetadata = latestMetadataEvent, latestIdentities = latestIdentitiesEvent, onDismiss = { showEditProfile = false }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 08ace28d94..091ef90023 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -88,7 +88,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel -import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow @@ -101,6 +101,7 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.awt.datatransfer.DataFlavor import java.awt.dnd.DnDConstants @@ -135,6 +136,7 @@ fun ChatPane( cacheProvider: ICacheProvider, feedViewModel: ChatroomFeedViewModel, messageState: ChatNewMessageState, + blossomServers: StateFlow>? = null, dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, onNavigateToProfile: (String) -> Unit = {}, onBack: (() -> Unit)? = null, @@ -379,6 +381,7 @@ fun ChatPane( roomKey = roomKey, account = account, cacheProvider = cacheProvider, + blossomServers = blossomServers, ) attachedFiles.clear() } catch (e: Exception) { @@ -880,9 +883,10 @@ private suspend fun sendEncryptedFiles( roomKey: ChatroomKey, account: IAccount, cacheProvider: ICacheProvider, + blossomServers: StateFlow>?, ) { val orchestrator = UploadOrchestrator() - val server = cacheProvider.preferredBlossomServer(account.pubKey) + val server = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) }.map { it.toPTag() } for (file in files) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 8823c6197f..c5cd49e850 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -271,6 +271,7 @@ private fun CompactMessagesContent( cacheProvider = cacheProvider, feedViewModel = feedViewModel, messageState = messageState, + blossomServers = (account as? DesktopIAccount)?.blossomServerList?.flow, dmBroadcastStatus = broadcastStatus, onNavigateToProfile = onNavigateToProfile, onBack = { listState.clearSelection() }, @@ -375,6 +376,7 @@ private fun SplitMessagesContent( cacheProvider = cacheProvider, feedViewModel = feedViewModel, messageState = messageState, + blossomServers = (account as? DesktopIAccount)?.blossomServerList?.flow, dmBroadcastStatus = broadcastStatus, onNavigateToProfile = onNavigateToProfile, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index ac1d8b27fd..44f61806af 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -472,6 +472,7 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, + blossomServers = iAccount.blossomServerList.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = {}, @@ -553,6 +554,7 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, + blossomServers = iAccount.blossomServerList.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = {}, @@ -705,6 +707,7 @@ internal fun OverlayContent( relayManager = relayManager, localCache = localCache, account = account, + blossomServers = iAccount?.blossomServerList?.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = onBack, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index 5aba2eb7f4..affca46175 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -80,8 +80,7 @@ import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.desktop.account.AccountState -import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache -import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -93,6 +92,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.mapNotNull @@ -125,7 +125,7 @@ sealed class Nip05Status { fun EditProfileDialog( account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, - localCache: DesktopLocalCache, + blossomServers: StateFlow>? = null, latestMetadata: MetadataEvent?, latestIdentities: ExternalIdentitiesEvent?, onDismiss: () -> Unit, @@ -173,7 +173,7 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = localCache.preferredBlossomServer(account.pubKeyHex) + val serverBaseUrl = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER fun uploadFile( file: File, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt index fbdb45791d..6cdb574b23 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.desktop.cache import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState -import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER -import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -98,23 +96,4 @@ class DesktopBlossomServerListTest { assertEquals(servers, state.getBlossomServersList()?.servers()) assertEquals(servers, state.flow.first { it.isNotEmpty() }) } - - @Test - fun `preferredBlossomServer reads the account's first server from cache`() = - runTest { - val cache = DesktopLocalCache() - val signer = NostrSignerInternal(KeyPair()) - val servers = listOf("https://first.example.com", "https://second.example.com") - cache.consume(signedServerList(servers, signer), relayUrl) - - assertEquals("https://first.example.com", cache.preferredBlossomServer(signer.pubKey)) - } - - @Test - fun `preferredBlossomServer falls back to the default when the account has no list`() { - val cache = DesktopLocalCache() - val signer = NostrSignerInternal(KeyPair()) - - assertEquals(DEFAULT_BLOSSOM_SERVER, cache.preferredBlossomServer(signer.pubKey)) - } } From f727c226c0af178cd00bb15111129839ca20918a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:44:49 +0000 Subject: [PATCH 13/45] refactor(desktop): provide blossom servers via LocalBlossomServers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the threaded blossomServers parameters with a LocalBlossomServers CompositionLocal, provided once from the account's state holder (iAccount.blossomServerList.flow) around the logged-in UI. Upload sites read the list from context instead of receiving it down a parameter chain. - Provide LocalBlossomServers in MainContent's existing provider (covers feeds, chats, profile, settings) and around the top-level compose dialog. - ComposeNoteDialog, EditProfileDialog, ChatPane and the media-server settings section read LocalBlossomServers.current; drop the params and the prop-drilling through UserProfileScreen and DesktopMessagesScreen. - This also covers quote-compose from a feed row (NoteActionsRow), which the parameter approach couldn't reach — the per-note card composables now get the list from context, so it no longer defaults to the primal server. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2 --- .../vitorpamplona/amethyst/desktop/Main.kt | 33 ++++++++++-------- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 7 ++-- .../amethyst/desktop/ui/FeedScreen.kt | 1 - .../desktop/ui/LocalBlossomServers.kt | 34 +++++++++++++++++++ .../amethyst/desktop/ui/NoteActions.kt | 2 -- .../amethyst/desktop/ui/UserProfileScreen.kt | 2 -- .../amethyst/desktop/ui/chats/ChatPane.kt | 4 ++- .../desktop/ui/chats/DesktopMessagesScreen.kt | 2 -- .../desktop/ui/deck/DeckColumnContainer.kt | 4 --- .../desktop/ui/profile/EditProfileScreen.kt | 5 ++- 10 files changed, 61 insertions(+), 33 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalBlossomServers.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index b31735110f..ec885a40b6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -112,6 +112,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen import com.vitorpamplona.amethyst.desktop.ui.ImportFollowListDialog +import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog @@ -1471,19 +1472,23 @@ private fun AppInner( } } - // Compose dialog + // Compose dialog. Hosted outside MainContent's provider, + // so provide the account's blossom list here too. if (showComposeDialog) { - ComposeNoteDialog( - onDismiss = onDismissComposeDialog, - relayManager = relayManager, - account = account, - localCache = localCache, - blossomServers = iAccount.blossomServerList.flow, - replyTo = replyToNote, - draftDTag = composeEditDraftTag, - draftInitialContent = composeEditContent, - initialScheduledForSec = composeEditScheduledForSec, - ) + CompositionLocalProvider( + LocalBlossomServers provides iAccount.blossomServerList.flow, + ) { + ComposeNoteDialog( + onDismiss = onDismissComposeDialog, + relayManager = relayManager, + account = account, + localCache = localCache, + replyTo = replyToNote, + draftDTag = composeEditDraftTag, + draftInitialContent = composeEditContent, + initialScheduledForSec = composeEditScheduledForSec, + ) + } } // App Drawer overlay @@ -1972,6 +1977,7 @@ fun MainContent( CompositionLocalProvider( LocalRelayCategories provides relayCategories, + LocalBlossomServers provides iAccount.blossomServerList.flow, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, @@ -2225,7 +2231,6 @@ fun RelaySettingsScreen( .TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF), onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {}, namecoinPreferences: DesktopNamecoinPreferences? = null, - blossomServers: kotlinx.coroutines.flow.StateFlow>? = null, onBlossomServersChanged: (List) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -2351,7 +2356,7 @@ fun RelaySettingsScreen( Spacer(Modifier.height(24.dp)) // Media Server Settings (Blossom, kind 10063 — synced with mobile) - val networkBlossomServers by (blossomServers?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) + val networkBlossomServers by (LocalBlossomServers.current?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) // The kind-10063 list is authoritative; before it loads (or when the // user has published none) show the default server. val effectiveBlossomServers = networkBlossomServers.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 189437556e..9eedb59385 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -118,7 +118,6 @@ import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.datatransfer.DataFlavor @@ -140,7 +139,6 @@ fun ComposeNoteDialog( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, localCache: com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache? = null, - blossomServers: StateFlow>? = null, replyTo: Event? = null, quoteOf: Event? = null, draftDTag: String? = null, @@ -208,8 +206,9 @@ fun ComposeNoteDialog( val uploadState by uploadTracker.state.collectAsState() val orchestrator = remember { UploadOrchestrator() } // Media servers come straight from the account's kind-10063 list holder - // (account.blossomServerList.flow), reactively — no cache-poking, no prefs. - val serverList by (blossomServers?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) + // (account.blossomServerList.flow), provided via LocalBlossomServers — + // reactively, no cache-poking, no prefs. + val serverList by (LocalBlossomServers.current?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) val effectiveServers = serverList.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } var selectedServer by remember { mutableStateOf(effectiveServers.first()) } // If the list loads (or changes) after the dialog opens and the current pick diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 096bf03a59..1eace52ec5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -1043,7 +1043,6 @@ fun FeedScreen( relayManager = relayManager, account = account, localCache = localCache, - blossomServers = iAccount?.blossomServerList?.flow, replyTo = replyToEvent, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalBlossomServers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalBlossomServers.kt new file mode 100644 index 0000000000..2cb0aab846 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalBlossomServers.kt @@ -0,0 +1,34 @@ +/* + * 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.desktop.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.flow.StateFlow + +/** + * The logged-in account's Blossom media server list (NIP-B7 / kind 10063), + * exposed as the reactive flow from the account's `BlossomServerListState` + * (`iAccount.blossomServerList.flow`). Provided once around the logged-in UI so + * upload composables read the list from context instead of threading it through + * every screen. `null` when no account is logged in — consumers fall back to + * [com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER]. + */ +val LocalBlossomServers = staticCompositionLocalOf>?> { null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 1c51828dd5..7a52fb1d08 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -1376,8 +1376,6 @@ fun NoteActionsRow( relayManager = relayManager, account = account, localCache = localCache, - // Quote-compose from a feed row falls back to the default media server: - // the per-note card composables don't carry the account's blossom flow. quoteOf = quoteEvent, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 50274077bb..8540b85045 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -122,7 +122,6 @@ fun UserProfileScreen( relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn?, - blossomServers: kotlinx.coroutines.flow.StateFlow>? = null, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, @@ -1190,7 +1189,6 @@ fun UserProfileScreen( EditProfileDialog( account = account, relayManager = relayManager, - blossomServers = blossomServers, latestMetadata = latestMetadataEvent, latestIdentities = latestIdentitiesEvent, onDismiss = { showEditProfile = false }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 091ef90023..3720ed4a64 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -89,6 +89,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER +import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow @@ -136,13 +137,14 @@ fun ChatPane( cacheProvider: ICacheProvider, feedViewModel: ChatroomFeedViewModel, messageState: ChatNewMessageState, - blossomServers: StateFlow>? = null, dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, onNavigateToProfile: (String) -> Unit = {}, onBack: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() + // Account's Blossom media servers (kind 10063), read from context. + val blossomServers = LocalBlossomServers.current val feedState by feedViewModel.feedState.feedContent.collectAsState() val messageText by messageState.message.collectAsState() val recipientsMissingRelays by messageState.recipientsMissingDmRelays.collectAsState() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index c5cd49e850..8823c6197f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -271,7 +271,6 @@ private fun CompactMessagesContent( cacheProvider = cacheProvider, feedViewModel = feedViewModel, messageState = messageState, - blossomServers = (account as? DesktopIAccount)?.blossomServerList?.flow, dmBroadcastStatus = broadcastStatus, onNavigateToProfile = onNavigateToProfile, onBack = { listState.clearSelection() }, @@ -376,7 +375,6 @@ private fun SplitMessagesContent( cacheProvider = cacheProvider, feedViewModel = feedViewModel, messageState = messageState, - blossomServers = (account as? DesktopIAccount)?.blossomServerList?.flow, dmBroadcastStatus = broadcastStatus, onNavigateToProfile = onNavigateToProfile, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 44f61806af..9503e2e0a8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -472,7 +472,6 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, - blossomServers = iAccount.blossomServerList.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = {}, @@ -503,7 +502,6 @@ internal fun RootContent( torSettings = torState.settings, onTorSettingsChanged = torState.onSettingsChanged, namecoinPreferences = LocalNamecoinPreferences.current, - blossomServers = iAccount.blossomServerList.flow, onBlossomServersChanged = { servers -> // Publish a kind-10063 event so the list syncs to every // Amethyst client, then consume it locally so state updates @@ -554,7 +552,6 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, - blossomServers = iAccount.blossomServerList.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = {}, @@ -707,7 +704,6 @@ internal fun OverlayContent( relayManager = relayManager, localCache = localCache, account = account, - blossomServers = iAccount?.blossomServerList?.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = onBack, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index affca46175..3a9b606d44 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client @@ -92,7 +93,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.mapNotNull @@ -125,7 +125,6 @@ sealed class Nip05Status { fun EditProfileDialog( account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, - blossomServers: StateFlow>? = null, latestMetadata: MetadataEvent?, latestIdentities: ExternalIdentitiesEvent?, onDismiss: () -> Unit, @@ -173,7 +172,7 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER + val serverBaseUrl = LocalBlossomServers.current?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER fun uploadFile( file: File, From a347889c57a5c99ef14e8b4cc5567733bee68635 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:52:20 +0000 Subject: [PATCH 14/45] fix(nip29): audit fixes for pinned-message jump and back-fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Jump effect: key on the note id alone and always clear the request after one attempt. Previously keyed on (id, items.list), so a message arriving mid-jump cancelled the scroll animation and could leave the request stuck (never cleared when the target wasn't loaded), blocking repeat taps. Now the animation can't be interrupted and a re-tap always re-fires. - Back-fill pinned bodies: add an id-scoped filter for the group's pinnedEventIds on the host relay. A pin can point at a message older than the 200-item timeline window, which the h-scoped timeline never returns — without this the bar shows a blank preview and can't jump. - Re-invalidate the relay-group filter when the pin list changes (keyed on the pin ids only, so roster/metadata churn doesn't re-subscribe), so the new pin ids are actually requested when 39005 arrives. --- .../loggedIn/chats/feed/ChatFeedView.kt | 10 ++--- .../ChannelFilterAssemblerSubscription.kt | 15 +++++++ .../FilterMetadataToRelayGroup.kt | 41 +++++++++++++------ 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 6ce084fb51..d00d3120de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -194,18 +194,18 @@ fun ChatFeedLoaded( } } - // External jump request (pinned-message bar). Re-runs when the requested id changes or the loaded - // list shifts (so a pin that arrives after the tap still scrolls once loaded). Clears the request - // as soon as it lands, so a repeat tap on the same pin fires again. + // External jump request (pinned-message bar). Keyed on the id alone, so a message arriving mid-jump + // can't cancel the scroll animation or restart the effect. Always clears the request after one + // attempt — even when the target isn't loaded — so it never sticks and a repeat tap fires again. val jumpId = jumpToNoteId?.value - LaunchedEffect(jumpId, items.list) { + LaunchedEffect(jumpId) { if (jumpId != null) { val index = items.list.indexOfFirst { it.idHex == jumpId } if (index >= 0) { listState.animateScrollToItem(index) highlightedNoteId.value = jumpId - onJumpHandled() } + onJumpHandled() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt index 7c748382b6..e0154c8373 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -58,4 +59,18 @@ fun ChannelFilterAssemblerSubscription( dataSource.invalidateFilters() } } + + // Relay groups: when the kind-39005 pin list changes, re-invalidate so the id-based back-fill + // for pinned message bodies (see filterMetadataToRelayGroup) picks up the new ids. Keyed on the + // pin list alone, so unrelated roster/metadata churn doesn't force a re-subscribe. + if (channel is RelayGroupChannel) { + val metadataState by channel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val pinnedIds = (metadataState.channel as? RelayGroupChannel)?.pinnedEventIds ?: channel.pinnedEventIds + LaunchedEffect(pinnedIds) { + dataSource.invalidateFilters() + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt index 1e62bbbe40..005b066e1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMetadataToRelayGroup.kt @@ -49,15 +49,32 @@ private val RELAY_GROUP_METADATA_KINDS = fun filterMetadataToRelayGroup( channel: RelayGroupChannel, since: SincePerRelayMap?, -): List = - channel.relays().toSet().map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = RELAY_GROUP_METADATA_KINDS, - tags = mapOf("d" to listOf(channel.groupId.id)), - since = since?.get(it)?.time, - ), - ) - } +): List { + val relays = channel.relays().toSet() + val directory = + relays.map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = RELAY_GROUP_METADATA_KINDS, + tags = mapOf("d" to listOf(channel.groupId.id)), + since = since?.get(it)?.time, + ), + ) + } + + // Back-fill the bodies of pinned messages by id from the host relay. A pin can point at a + // message older than the 200-item timeline window, which the `h`-scoped timeline filter would + // never return — without this the pin bar shows a blank preview and can't jump to it. No `since`: + // pinned events are immutable, so we want them regardless of age. + val pinnedIds = channel.pinnedEventIds + val pins = + if (pinnedIds.isEmpty()) { + emptyList() + } else { + relays.map { RelayBasedFilter(relay = it, filter = Filter(ids = pinnedIds)) } + } + + return directory + pins +} From c5ad932c1bc865d7eb0384a7c6db7538424f570d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:54:07 +0000 Subject: [PATCH 15/45] feat(desktop): make DM relay-list prewarm viewport-driven, not a full sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass warmed every conversation peer on disk from the 2s refresh loop. Scope it to what the user is actually looking at instead: - Move the prewarm to a reusable, deduped, concurrency-capped DesktopIAccount.prewarmDmInboxRelays(pubkeys) any DM surface can call. - Trigger it per-row from the conversation list's LazyColumn, which only composes visible rows (+ buffer), so peers warm as their row scrolls into view and no earlier — scrolling warms more. - Warm a recipient the moment they're picked in the New DM dialog, so the kind:10050 is ready before the composer opens. - Room open already resolves peers via ChatNewMessageState.load(). ChatroomListState.prewarmPeerRelays now just delegates to the account. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3 --- .../amethyst/desktop/model/DesktopIAccount.kt | 45 ++++++++++++++ .../desktop/ui/chats/ChatroomListState.kt | 58 +++---------------- .../desktop/ui/chats/ConversationListPane.kt | 8 +++ .../desktop/ui/chats/DesktopMessagesScreen.kt | 1 + .../amethyst/desktop/ui/chats/NewDmDialog.kt | 11 +++- 5 files changed, 71 insertions(+), 52 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index e85c610398..133e0d90a1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -56,7 +56,11 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.util.concurrent.ConcurrentHashMap /** * Desktop implementation of IAccount. @@ -297,6 +301,47 @@ class DesktopIAccount( return recipients.associateWith { resolveDmInboxRelaysStrictOrdered(it).firstOrNull() } } + // Peers whose kind:10050 we've already kicked off a prewarm for. Prewarm is + // best-effort and idempotent — once requested we don't re-request, because + // the resolver's own LRU (and, once a 10050 arrives, LocalCache) serves + // subsequent lookups. The send/pre-send paths call the resolver directly + // and are NOT gated by this set, so they always see fresh-within-TTL data. + private val prewarmedDmInboxKeys = ConcurrentHashMap.newKeySet() + + // Cap concurrent kind:10050 fan-outs so scrolling a long conversation list + // (or opening several rooms quickly) doesn't burst the indexer relays. + private val dmInboxPrewarmLimiter = Semaphore(4) + + /** + * Proactively resolve (and cache) the NIP-17 DM inbox relays (kind:10050) + * for [pubkeys], so the first message/reaction sent to any of them resolves + * from cache instead of paying an indexer round-trip, and the composer's + * "recipient has no DM relays" gate settles before the user starts typing. + * + * Reusable app-wide: any surface that displays a DM (the visible rows of the + * conversation list, an opened room, a future notification/preview) should + * call this so the relay list is downloaded before the user drops into the + * room. Viewport-scoped by design — callers pass only the peers they are + * actually showing, so a large conversation list only warms what's visible + * and warms more as the user scrolls. + */ + fun prewarmDmInboxRelays(pubkeys: Collection) { + val resolver = dmInboxResolver ?: return + for (pubkey in pubkeys) { + if (pubkey.length != 64 || !prewarmedDmInboxKeys.add(pubkey)) continue + scope.launch(Dispatchers.IO) { + dmInboxPrewarmLimiter.withPermit { + try { + resolver.resolve(pubkey) + } catch (_: Exception) { + // Best-effort prefetch; the send path resolves again on demand. + prewarmedDmInboxKeys.remove(pubkey) + } + } + } + } + } + private fun addEventToChatroom( event: com.vitorpamplona.quartz.nip01Core.core.Event, roomKey: com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt index 61ae9351a1..d636037723 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt @@ -43,8 +43,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit /** * Represents a conversation entry in the list pane. @@ -104,14 +102,6 @@ class ChatroomListState( // Track pubkeys we've already requested metadata for private val fetchedMetadataKeys = mutableSetOf() - // Track pubkeys whose NIP-17 DM relay list (kind 10050) we've already - // prewarmed, so the 2s refresh loop only kicks off one fetch per peer. - private val prewarmedDmRelayKeys = mutableSetOf() - - // Bound concurrent kind:10050 lookups so a large conversation list doesn't - // flood the curated indexer relays with a burst of parallel fan-outs. - private val dmRelayPrewarmLimiter = Semaphore(4) - // Timestamp (createdAt) of the newest message seen by the user per room, set when a room is // opened. A room is unread when its newest incoming message is newer than this mark. private val lastSeen = mutableMapOf() @@ -207,38 +197,14 @@ class ChatroomListState( } /** - * Proactively download every conversation peer's NIP-17 DM relay list - * (kind 10050) as soon as their room shows up in the list, rather than - * waiting until the user opens the room or hits send. - * - * NIP-17 gift wraps (both messages and reactions) MUST be delivered to the - * recipient's kind:10050 relays; the desktop send path resolves those via - * [DesktopIAccount.dmInboxResolver]. Warming the resolver here means the - * first send/reaction resolves from cache instead of paying an indexer - * round-trip, and the composer's "recipient has no DM relays" gate settles - * before the user has typed anything. - * - * The resolver already dedupes and caches, but we also keep our own - * [prewarmedDmRelayKeys] guard so the 2s refresh loop launches at most one - * fetch per peer, and cap concurrency via [dmRelayPrewarmLimiter] so a big - * conversation list doesn't fan out to the indexers all at once. + * Proactively download the NIP-17 DM relay lists (kind 10050) for the given + * conversation peers. Called from the list UI for the rows that are actually + * visible (and again as more scroll into view), so we only warm what the + * user is looking at rather than every conversation on disk. Delegates to + * the reusable, deduped [DesktopIAccount.prewarmDmInboxRelays]. */ - private fun prewarmDmRelayLists(pubkeys: Collection) { - val resolver = (account as? DesktopIAccount)?.dmInboxResolver ?: return - val needed = pubkeys.filter { it.length == 64 && prewarmedDmRelayKeys.add(it) } - if (needed.isEmpty()) return - - for (pubkey in needed) { - scope.launch(Dispatchers.IO) { - dmRelayPrewarmLimiter.withPermit { - try { - resolver.resolve(pubkey) - } catch (_: Exception) { - // Best-effort prefetch; the send path resolves again on demand. - } - } - } - } + fun prewarmPeerRelays(pubkeys: Collection) { + (account as? DesktopIAccount)?.prewarmDmInboxRelays(pubkeys) } private suspend fun refreshRooms() { @@ -253,10 +219,6 @@ class ChatroomListState( // Collect pubkeys needing metadata across all rooms val pubkeysNeedingMetadata = mutableListOf() - // Collect every peer pubkey so we can proactively download their NIP-17 - // DM relay list (kind 10050) — see [prewarmDmRelayLists]. - val dmPeerPubkeys = mutableSetOf() - for ((key, chatroom) in entries) { // Skip rooms with no messages if (chatroom.messages.isEmpty()) continue @@ -265,8 +227,6 @@ class ChatroomListState( val newestMessage = chatroom.newestMessage if (newestMessage != null && !account.isAcceptable(newestMessage)) continue - dmPeerPubkeys.addAll(key.users) - val users = key.users.mapNotNull { cacheProvider.getUserIfExists(it) } // Collect pubkeys without profile info @@ -317,10 +277,6 @@ class ChatroomListState( // Batch-request metadata for all users who need it fetchMetadataIfNeeded(pubkeysNeedingMetadata) - // Proactively download each peer's DM relay list (kind 10050) so the - // NIP-17 send path resolves it from cache instead of on demand. - prewarmDmRelayLists(dmPeerPubkeys) - // Sort by most recent message _knownRooms.value = known.sortedByDescending { it.lastMessageTimestamp } _newRooms.value = new.sortedByDescending { it.lastMessageTimestamp } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index 79c470c152..337b738e8b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -43,6 +43,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -258,6 +259,13 @@ fun ConversationListPane( modifier = Modifier.weight(1f), ) { items(currentList, key = { it.roomKey.hashCode() }) { item -> + // Viewport-driven prewarm: a LazyColumn only composes the + // rows that are visible (plus a small buffer), so this warms + // each peer's NIP-17 DM relay list exactly when their row + // scrolls into view — and no earlier. + LaunchedEffect(item.roomKey) { + state.prewarmPeerRelays(item.roomKey.users) + } val index = currentList.indexOf(item) val isFocused = index == focusedIndex ConversationCard( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 8823c6197f..a2668c14bf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -186,6 +186,7 @@ fun DesktopMessagesScreen( showNewDmDialog = false }, onDismiss = { showNewDmDialog = false }, + onPrewarmDmRelays = { (account as? DesktopIAccount)?.prewarmDmInboxRelays(it) }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt index cc6f77a3f5..08f9c6e014 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -75,6 +75,7 @@ fun NewDmDialog( localCache: DesktopLocalCache, onUserSelected: (ChatroomKey) -> Unit, onDismiss: () -> Unit, + onPrewarmDmRelays: (Collection) -> Unit = {}, ) { val scope = rememberCoroutineScope() // Recipients selected so far. One → 1:1 chat, more than one → group chat. @@ -82,7 +83,15 @@ fun NewDmDialog( fun toggle(user: User) { val existing = selected.firstOrNull { it.pubkeyHex == user.pubkeyHex } - if (existing != null) selected.remove(existing) else selected.add(user) + if (existing != null) { + selected.remove(existing) + } else { + selected.add(user) + // Selecting a recipient is a strong signal we're about to DM them — + // download their kind:10050 now so the relay list is ready by the + // time the composer opens. + onPrewarmDmRelays(listOf(user.pubkeyHex)) + } } fun isSelected(user: User) = selected.any { it.pubkeyHex == user.pubkeyHex } From 152fc76bc09b9526d057961b487f062ee306ff3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 00:03:38 +0000 Subject: [PATCH 16/45] fix(desktop): fetch account config (incl. blossom) from outbox relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account-config bootstrap subscription only queried the default relays, so a user's Blossom server list (kind 10063) — published to their own write relays, not the defaults — was never fetched, and the UI fell back to the default server. NIP-65 relay lists hid the same gap because they're broadcast widely and also have a local backup. Add a subscription that re-fetches the account-config kinds (10002/10050/ 10007/10006/10063) from the user's NIP-65 outbox (write + untagged relays) once it's known, routing kind 10063 / 10002 through justConsumeMyOwnEvent like the bootstrap does. This matches mobile's outbox model: the user's own data comes from their write relays. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2 --- .../vitorpamplona/amethyst/desktop/Main.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index ec885a40b6..ff86c0acb4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1728,6 +1728,52 @@ fun MainContent( onDispose { relayManager.unsubscribe(bootstrapSubId) } } + // The bootstrap subscription above only reaches the default relays. A user's + // own account data — Blossom server list (kind 10063), DM/search/blocked + // relay lists — is published to their write relays, not the defaults, so it + // won't be found there. Re-fetch those kinds from the NIP-65 outbox + // (write + untagged relays) whenever it becomes known. + LaunchedEffect(iAccount) { + iAccount.nip65RelayList.outboxFlow.collect { outbox -> + if (outbox.isEmpty()) return@collect + relayManager.subscribe( + subId = "account-config-outbox", + filters = + listOf( + Filter( + kinds = + listOf( + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + BlockedRelayListEvent.KIND, + BlossomServersEvent.KIND, + ), + authors = listOf(account.pubKeyHex), + limit = 10, + ), + ), + relays = outbox, + listener = + object : SubscriptionListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is AdvertisedRelayListEvent || event is BlossomServersEvent) { + scope.launch(Dispatchers.IO) { + localCache.justConsumeMyOwnEvent(event) + } + } + accountRelays.consumeIfRelevant(event) + } + }, + ) + } + } + // Subscribe to incoming DMs and process into chatroomList LaunchedEffect(account) { relayManager.connectedRelays.first { it.isNotEmpty() } From 2b30a85dce056871ccd56b3382393e26e246f8cc Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:13:55 +0000 Subject: [PATCH 17/45] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-zh-rCN/strings.xml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index aabd2942ac..448ad4565d 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -94,6 +94,7 @@ 安全过滤器 退出 显示更多 + 显示较少 闪电发票 已过期 CLINK 报价 @@ -1840,6 +1841,26 @@ 新建公开或私人群组 + 开始对话 + 最适用于 + 好 + 折衷办法 + 私密 + 加密群组 + 识别中继 + 私信 + 私信,端到端加密 + 1:1–5 个房间 + 和你认识的人的一对一及小型群聊。 + 新消息 + 1-1 & 小型群组 + 端到端加密的 + 稍后无法添加用户 + Marmot 群 + 较大的私密群 + 绑定设备 + 规模较大的私密群组,不再适合小型群组私信。 + 创建群组 中继组 行内 根据中继 From 931236e71920f063472a1a01c0e0f4cdb13e7b51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 00:15:28 +0000 Subject: [PATCH 18/45] feat(desktop): back-fill kind:10002 on profile load and read DM inbox from outbox relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers "do we load kind:10050 from each user's outbox write relays?" — now we do, and we make sure we know where those relays are. - DmInboxRelayResolver.resolve is now two-phase: query the curated indexers ∪ the recipient's known write relays (via a new outboxLookup) for 10050/10002; if the indexers didn't carry the 10050, read it directly from the write relays learned from their kind:10002 — the canonical NIP-65 outbox location. Wired on desktop with cachedAdvertisedRelayList(pubkey).writeRelaysNorm(). - Whenever DesktopLocalCache ingests a user's kind:0, it fires onProfileMetadataConsumed; the subscriptions coordinator back-fills that user's kind:10002 (batched ≤100 authors/REQ, deduped, one REQ at a time) and routes it through consume() so it's saved. So learning who a user is now also learns where they write. Tests: DmInboxRelayResolverOutboxTest covers reading 10050 from the recipient's write relays (indexers only have their 10002) and the cached-outbox seed path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3 --- .../nip17Dm/DmInboxRelayResolver.kt | 63 +++++-- .../nip17Dm/DmInboxRelayResolverOutboxTest.kt | 154 ++++++++++++++++++ .../vitorpamplona/amethyst/desktop/Main.kt | 18 +- .../desktop/cache/DesktopLocalCache.kt | 14 ++ .../DesktopRelaySubscriptionsCoordinator.kt | 73 +++++++++ 5 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt index 301ebd7e98..977782eda7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt @@ -30,20 +30,22 @@ import kotlinx.coroutines.sync.withLock /** * Resolves a recipient's NIP-17 inbox relays (kind:10050) for DM delivery. * - * Three-layer lookup, in order: + * Lookup order: * * 1. **LocalCache hit** — the caller has already seen the user's kind:10050 * via the normal feed subscription pipeline. Cheapest; no I/O. * 2. **In-memory LRU cache** — a prior resolve() succeeded for this pubkey - * within the TTL. Avoids re-querying indexers when the user opens a - * conversation list and clicks several recipients in sequence. - * 3. **Indexer fan-out** — query a curated set of indexer relays for the - * user's kind:10050 via [RecipientRelayFetcher]. The client passed in - * here MUST be an **unauthenticated** instance (no [RelayAuthenticator] - * attached) — otherwise an indexer's AUTH challenge would extract an - * identity-key signature from the user, turning the metadata leak - * "indexer learns who we want to DM" into "indexer learns user U wants - * to DM pubkey X". + * within the TTL. Avoids re-querying when the user opens a conversation + * list and clicks several recipients in sequence. + * 3. **Network fan-out** ([resolve] does this in two phases) — query the + * curated indexers **plus** the recipient's known write relays for + * kind:10050/10002, and, if the indexers didn't have the 10050, read it + * directly from the recipient's NIP-65 write (outbox) relays — the + * canonical location per NIP-65. The client passed in here MUST be an + * **unauthenticated** instance (no [RelayAuthenticator] attached) — + * otherwise a relay's AUTH challenge would extract an identity-key + * signature from the user, turning the metadata leak "some relay learns + * who we want to DM" into "that relay learns user U wants to DM pubkey X". * * Filters to **kind:10050 only**. Per NIP-17 §Publishing, gift wraps MUST * land on relays in the recipient's kind:10050; this resolver never @@ -62,6 +64,12 @@ import kotlinx.coroutines.sync.withLock * @property localLookup Callback the resolver invokes first to check the * LocalCache — returns the user's current kind:10050 list or null if * unknown. Allows commons/headless callers to plug in a CLI-safe lookup. + * @property outboxLookup Callback returning the recipient's NIP-65 write + * (outbox) relays from the recipient's cached kind:10002, or null/empty when + * unknown. Per the NIP-65 outbox model a user's replaceable events — kind:10050 + * included — live on their own write relays, so those are queried directly (in + * addition to the curated indexers) rather than trusting the indexers to have + * mirrored the 10050. Returns null by default (indexer-only, e.g. the CLI). * @property cacheTtlMs LRU cache TTL. 1h matches the brainstorm's open * question; configurable here for tests. * @property cacheSize LRU bound. 100 entries × ~200 bytes each is trivial @@ -71,6 +79,7 @@ class DmInboxRelayResolver( private val unauthenticatedClient: INostrClient, private val indexerRelays: Set, private val localLookup: (HexKey) -> List?, + private val outboxLookup: (HexKey) -> List? = { null }, private val cacheTtlMs: Long = 60 * 60 * 1_000L, private val cacheSize: Int = 100, private val nowMs: () -> Long = { @@ -88,8 +97,17 @@ class DmInboxRelayResolver( private val mutex = Mutex() /** - * Resolve `pubkey`'s NIP-17 inbox relays. Returns empty list if neither - * the LocalCache nor the indexer fan-out yielded a kind:10050. + * Resolve `pubkey`'s NIP-17 inbox relays. Returns empty list if neither the + * LocalCache, the recipient's outbox relays, nor the indexer fan-out yielded + * a kind:10050. + * + * Two network phases against the unauthenticated client: + * 1. Query the curated indexers **plus** the recipient's known write relays + * (from a cached kind:10002) for kinds 10050/10002 in one shot. + * 2. If that still produced no kind:10050 but we now know the recipient's + * write relays (from the kind:10002 the indexers returned, or the cache), + * read kind:10050 directly from those write relays — the canonical + * NIP-65 outbox location, which the curated indexers may not mirror. */ suspend fun resolve(pubkey: HexKey): List { localLookup(pubkey)?.takeIf { it.isNotEmpty() }?.let { return it } @@ -108,12 +126,27 @@ class DmInboxRelayResolver( } } - if (indexerRelays.isEmpty()) return emptyList() + val cachedOutbox = outboxLookup(pubkey)?.toSet().orEmpty() + val phase1Seed = indexerRelays + cachedOutbox + if (phase1Seed.isEmpty()) return emptyList() - val lists = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, indexerRelays) + // Phase 1: indexers ∪ known outbox relays. + val lists = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, phase1Seed) // Strict: kind:10050 ONLY. No NIP-65 fallback. Empty = canonical // "unreachable" signal; caller refuses to publish. - val relays = lists.dmInbox + var relays = lists.dmInbox + + // Phase 2: the recipient's own write relays are where their kind:10050 + // lives per NIP-65. If phase 1 (mostly the curated indexers) didn't + // surface it, read straight from the write relays we just learned about + // — the kind:10002 the indexers returned, unioned with anything cached — + // minus what we already queried. + if (relays.isEmpty()) { + val writeRelays = (lists.nip65Write().toSet() + cachedOutbox) - phase1Seed + if (writeRelays.isNotEmpty()) { + relays = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, writeRelays).dmInbox + } + } mutex.withLock { cache[pubkey] = Entry(relays, now + cacheTtlMs) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt new file mode 100644 index 0000000000..1e22c715f1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt @@ -0,0 +1,154 @@ +/* + * 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.commons.relayClient.nip17Dm + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Covers the NIP-65 outbox path of [DmInboxRelayResolver.resolve]: when the + * curated indexers do NOT carry the recipient's kind:10050 but DO know their + * kind:10002, the resolver must read the 10050 straight from the recipient's + * declared write (outbox) relays. + */ +class DmInboxRelayResolverOutboxTest { + private val peer: HexKey = "a".repeat(64) + + private val indexer = NormalizedRelayUrl("wss://indexer.test/") + private val peerOutbox = NormalizedRelayUrl("wss://peer-write.test/") + private val peerDmInbox = NormalizedRelayUrl("wss://peer-dm.test/") + + private fun dummySig() = "0".repeat(128) + + private fun outbox10002(write: List) = + AdvertisedRelayListEvent( + id = "b".repeat(64), + pubKey = peer, + createdAt = 1_700_000_000, + tags = write.map { arrayOf("r", it.url, "write") }.toTypedArray(), + content = "", + sig = dummySig(), + ) + + private fun dm10050(inbox: List) = + ChatMessageRelayListEvent( + id = "c".repeat(64), + pubKey = peer, + createdAt = 1_700_000_050, + tags = ChatMessageRelayListEvent.createTagArray(inbox), + content = "", + sig = dummySig(), + ) + + /** + * Replays scripted events per (kind, relay) and auto-EOSEs on subscribe — + * enough for [fetchAll] which awaits one EOSE per relay. + */ + private class ScriptedClient( + delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + private val script = mutableMapOf, List>() + val queriedRelays = mutableSetOf() + + fun scriptEvent( + kind: Int, + relay: NormalizedRelayUrl, + events: List, + ) { + script[kind to relay] = events + } + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + filters.forEach { (relay, filterList) -> + queriedRelays.add(relay) + filterList.forEach { filter -> + filter.kinds?.forEach { kind -> + script[kind to relay]?.forEach { event -> + listener?.onEvent(event, isLive = false, relay = relay, forFilters = null) + } + } + } + listener?.onEose(relay, forFilters = null) + } + } + + override fun unsubscribe(subId: String) { /* no-op */ } + } + + @Test + fun `reads kind 10050 from the recipient write relays when indexers only have their 10002`() = + runBlocking { + val client = ScriptedClient() + // Indexer knows WHERE the peer writes (10002) but not their 10050. + client.scriptEvent(AdvertisedRelayListEvent.KIND, indexer, listOf(outbox10002(listOf(peerOutbox)))) + // The peer's own write relay serves their 10050. + client.scriptEvent(ChatMessageRelayListEvent.KIND, peerOutbox, listOf(dm10050(listOf(peerDmInbox)))) + + val resolver = + DmInboxRelayResolver( + unauthenticatedClient = client, + indexerRelays = setOf(indexer), + localLookup = { null }, + outboxLookup = { null }, + ) + + val result = resolver.resolve(peer) + + assertEquals(listOf(peerDmInbox), result) + assertTrue("must read 10050 from the peer's own write relay", peerOutbox in client.queriedRelays) + } + + @Test + fun `uses a cached outbox as a phase-1 seed`() = + runBlocking { + val client = ScriptedClient() + // No 10002 on the indexer; the write relay is only known from cache. + client.scriptEvent(ChatMessageRelayListEvent.KIND, peerOutbox, listOf(dm10050(listOf(peerDmInbox)))) + + val resolver = + DmInboxRelayResolver( + unauthenticatedClient = client, + indexerRelays = setOf(indexer), + localLookup = { null }, + outboxLookup = { listOf(peerOutbox) }, + ) + + val result = resolver.resolve(peer) + + assertEquals(listOf(peerDmInbox), result) + assertTrue("cached outbox relay must be queried in phase 1", peerOutbox in client.queriedRelays) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 9edf1cb95b..70dc672ce2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1019,6 +1019,14 @@ private fun AppInner( // metadata to relays the recipient never designated for DMs. localCache.getUserIfExists(pubkey)?.dmInboxRelaysStrict() }, + outboxLookup = { pubkey -> + // The recipient's kind:10050 lives on their NIP-65 write + // relays. When we've already cached their kind:10002 (via the + // feed / outbox dispatcher), hand those write relays to the + // resolver so it reads the 10050 from the source instead of + // relying on the curated indexers to have mirrored it. + localCache.cachedAdvertisedRelayList(pubkey)?.writeRelaysNorm() + }, ) } @@ -1050,7 +1058,15 @@ private fun AppInner( scope = scope, indexRelays = indexRelaysStore.effective(), localCache = localCache, - ).also { it.startCleanupLoop() } + ).also { coordinator -> + coordinator.startCleanupLoop() + // Whenever we ingest a user's kind:0 profile, back-fill their + // kind:10002 so their outbox (write) relays — where their + // kind:10050 and other replaceables live — are known. + localCache.onProfileMetadataConsumed = { pubkey -> + coordinator.ensureOutboxRelayList(pubkey) + } + } } // NIP-42 AUTH coordinator — wires relay-auth challenges through the diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 3ec2bc4f4f..8208d588c6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -199,6 +199,16 @@ class DesktopLocalCache : ICacheProvider { ).take(limit) } + /** + * Invoked (with the author's pubkey) every time a kind:0 profile is + * ingested. Wired by the subscriptions coordinator to back-fill that + * author's kind:10002 (NIP-65 outbox) — so whenever we learn who a user + * is, we also learn where they write, which is where their kind:10050 and + * other replaceables live. The handler dedupes and skips authors whose + * 10002 is already cached, so firing on every kind:0 is cheap. + */ + var onProfileMetadataConsumed: ((HexKey) -> Unit)? = null + /** * Updates user metadata from a MetadataEvent. * Called when receiving kind 0 events from relays. @@ -219,6 +229,10 @@ class DesktopLocalCache : ICacheProvider { } } } + + // Learn where this author writes (kind:10002) so their replaceables — + // kind:10050 included — can be read from their own outbox relays. + onProfileMetadataConsumed?.invoke(event.pubKey) } fun justVerify(event: Event): Boolean = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 33afa1921d..371b54f1b9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -162,6 +162,75 @@ class DesktopRelaySubscriptionsCoordinator( private val _lastEventAt = MutableStateFlow(null) val lastEventAt: StateFlow = _lastEventAt.asStateFlow() + // -- kind:10002 (NIP-65 outbox) back-fill for profiles we ingest -- + // Every author whose kind:0 we consume gets their kind:10002 fetched and + // cached, so the outbox model (and the DM inbox resolver) can read their + // replaceables — kind:10050 included — straight from their write relays. + // `outboxRequested` is a permanent per-session dedup: we ask once per + // author. `pendingOutbox` buffers authors between batched REQs. + private val outboxRequested = ConcurrentHashMap.newKeySet() + private val pendingOutbox = ConcurrentHashMap.newKeySet() + private var outboxBackfillJob: Job? = null + + /** + * Ensure we have (or have requested) [pubkey]'s kind:10002. No-op if it's + * already cached or already queued/asked. Safe to call on every kind:0 + * ingest — wire it to [DesktopLocalCache.onProfileMetadataConsumed]. + */ + fun ensureOutboxRelayList(pubkey: HexKey) { + if (localCache.cachedAdvertisedRelayList(pubkey) != null) return + if (!outboxRequested.add(pubkey)) return + pendingOutbox.add(pubkey) + scheduleOutboxBackfill() + } + + /** + * Drain [pendingOutbox] in batches of ≤100 authors, one REQ at a time, + * coalescing bursts with a short delay. kind:10002 lives on the index / + * discovery relays (they mirror it widely), so we query those; results go + * through [consumeEvent] → cache. Reschedules itself while work remains. + */ + private fun scheduleOutboxBackfill() { + if (outboxBackfillJob?.isActive == true) return + outboxBackfillJob = + scope.launch(Dispatchers.IO) { + // Coalesce a burst of profile arrivals into one batched REQ. + delay(500) + + val batch = pendingOutbox.take(100).toList() + if (batch.isEmpty()) return@launch + pendingOutbox.removeAll(batch.toSet()) + + if (indexRelays.isNotEmpty()) { + val subId = generateSubId("outbox-backfill") + val filter = + Filter( + kinds = listOf(AdvertisedRelayListEvent.KIND), + authors = batch, + limit = batch.size, + ) + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + consumeEvent(event, relay) + } + } + client.subscribe(subId, indexRelays.associateWith { listOf(filter) }, listener) + // Give the index relays time to return before closing. + delay(8.seconds) + client.unsubscribe(subId) + } + + // More arrived while we were fetching — go again. + if (pendingOutbox.isNotEmpty()) scheduleOutboxBackfill() + } + } + /** * Central event router — consumes an event into the cache and emits to event stream. * Called from relay onEvent callbacks. Non-blocking (launches on IO dispatcher). @@ -438,6 +507,10 @@ class DesktopRelaySubscriptionsCoordinator( outboxDispatcher.clear() rateLimiter.reset() cleanupJob?.cancel() + + outboxBackfillJob?.cancel() + outboxRequested.clear() + pendingOutbox.clear() } /** From 275df2ae9c3e587cbcea4a751aca87c02657c115 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 00:31:18 +0000 Subject: [PATCH 19/45] fix(desktop): drain kind:10002 back-fill fully and close on EOSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-ups on the DM relay-list work: - Bug: scheduleOutboxBackfill re-scheduled itself from inside the still-active drain job, so the isActive guard made the tail call a no-op — any authors past the first 100-author batch in a burst were stranded until another kind:0 happened to arrive. Drain in a while-loop inside one job instead, and mark it @Synchronized so concurrent consume-path callers can't spawn duplicate jobs. - Perf: the back-fill held each REQ open for a fixed 8s. Use fetchAll, which returns on EOSE (bounded by the timeout), and reuses existing infra. - DmInboxRelayResolver: drop the redundant `+ cachedOutbox` in the phase-2 seed (cached outbox is already in phase1Seed, so it was always subtracted back out), and bound the phase-2 fallback fetch to 5s so a cold send can't stack two full fan-out timeouts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3 --- .../nip17Dm/DmInboxRelayResolver.kt | 14 ++++-- .../DesktopRelaySubscriptionsCoordinator.kt | 47 +++++++++---------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt index 977782eda7..6c75108430 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt @@ -138,13 +138,17 @@ class DmInboxRelayResolver( // Phase 2: the recipient's own write relays are where their kind:10050 // lives per NIP-65. If phase 1 (mostly the curated indexers) didn't - // surface it, read straight from the write relays we just learned about - // — the kind:10002 the indexers returned, unioned with anything cached — - // minus what we already queried. + // surface it, read straight from the write relays the indexers' + // kind:10002 just pointed us at — minus what phase 1 already queried + // (which already includes any cached outbox relays). Bounded by a + // shorter timeout so a cold send doesn't stack two full fan-out waits. if (relays.isEmpty()) { - val writeRelays = (lists.nip65Write().toSet() + cachedOutbox) - phase1Seed + val writeRelays = lists.nip65Write().toSet() - phase1Seed if (writeRelays.isNotEmpty()) { - relays = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, writeRelays).dmInbox + relays = + RecipientRelayFetcher + .fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, timeoutMs = 5_000L) + .dmInbox } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 371b54f1b9..9aff62678a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -188,46 +189,42 @@ class DesktopRelaySubscriptionsCoordinator( * Drain [pendingOutbox] in batches of ≤100 authors, one REQ at a time, * coalescing bursts with a short delay. kind:10002 lives on the index / * discovery relays (they mirror it widely), so we query those; results go - * through [consumeEvent] → cache. Reschedules itself while work remains. + * through [consumeEvent] → cache. + * + * `@Synchronized` + the `isActive` guard make this launch-once: concurrent + * callers from the consume path don't spawn duplicate drain jobs. The job + * loops until [pendingOutbox] is empty, so authors that arrive mid-fetch + * (or a burst larger than one 100-author batch) are picked up in the same + * run instead of stranded until the next profile happens to arrive. */ + @Synchronized private fun scheduleOutboxBackfill() { if (outboxBackfillJob?.isActive == true) return outboxBackfillJob = scope.launch(Dispatchers.IO) { - // Coalesce a burst of profile arrivals into one batched REQ. + // Coalesce a burst of profile arrivals into the first batch. delay(500) - val batch = pendingOutbox.take(100).toList() - if (batch.isEmpty()) return@launch - pendingOutbox.removeAll(batch.toSet()) + while (pendingOutbox.isNotEmpty()) { + val batch = pendingOutbox.take(100).toList() + pendingOutbox.removeAll(batch.toSet()) + if (batch.isEmpty() || indexRelays.isEmpty()) continue - if (indexRelays.isNotEmpty()) { - val subId = generateSubId("outbox-backfill") val filter = Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), authors = batch, limit = batch.size, ) - val listener = - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - consumeEvent(event, relay) - } - } - client.subscribe(subId, indexRelays.associateWith { listOf(filter) }, listener) - // Give the index relays time to return before closing. - delay(8.seconds) - client.unsubscribe(subId) + // fetchAll closes on EOSE (bounded by the timeout) rather + // than holding the sub open for a fixed window. + val events = + client.fetchAll( + filters = indexRelays.associateWith { listOf(filter) }, + timeoutMs = 8.seconds.inWholeMilliseconds, + ) + events.forEach { consumeEvent(it, null) } } - - // More arrived while we were fetching — go again. - if (pendingOutbox.isNotEmpty()) scheduleOutboxBackfill() } } From 389d750ccce99a70ed8948696f93d41c61280adf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 00:49:15 +0000 Subject: [PATCH 20/45] fix(concord): don't hang forever on an unopenable invite link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redeeming a Concord invite link (`…/invite/#`) that was minted by a newer/incompatible Concord client left the redeem screen on an endless "Redeeming invite…" spinner with a retry button that could never succeed. The bundle event fetches fine, but its content can't be decrypted (e.g. a bundle tagged `vsk=8` vs the `vsk=6` this app mints/reads), so `ConcordInviteBundle.parse` returns null and the old `String?` contract collapsed every failure into a single generic "could not fetch" + retry. `joinConcordViaInvite` now returns a `ConcordInviteResult` that separates the outcomes — joined, invalid link, not reachable (transient), and a bundle that was found but couldn't be opened (incompatible/newer). The invite screen shows a specific message per case and only offers a retry for the transient miss, so an invite this app can't open ends on a clear explanation instead of looping. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD --- .../vitorpamplona/amethyst/model/Account.kt | 27 +++++++--- .../amethyst/model/ConcordInviteResult.kt | 50 +++++++++++++++++++ .../concord/ConcordInviteScreen.kt | 38 ++++++++++---- amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt 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 ab22669284..92bf6fb011 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2037,20 +2037,31 @@ class Account( * Redeem a Concord invite link (`…/invite/#`): parse it, fetch * the kind-33301 public bundle from the link's relays (+ our outbox), unlock it * with the fragment token, and add the resulting secret-bearing entry to the - * kind-13302 joined list. Returns the joined community id, or null if the link - * is invalid, unreadable, or no valid bundle is found. + * kind-13302 joined list. + * + * Returns a [ConcordInviteResult] that separates the failure modes so the UI can + * both explain what went wrong and decide whether a retry could ever help — a + * bundle we can't open (e.g. minted by a newer client) must not strand the user + * on a spinner that retries forever. */ - suspend fun joinConcordViaInvite(url: String): String? { - if (!isWriteable()) return null - val parsed = ConcordActions.parseInviteLink(url) ?: return null + suspend fun joinConcordViaInvite(url: String): ConcordInviteResult { + if (!isWriteable()) return ConcordInviteResult.InvalidLink + val parsed = ConcordActions.parseInviteLink(url) ?: return ConcordInviteResult.InvalidLink val relays = (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() - if (relays.isEmpty()) return null + if (relays.isEmpty()) return ConcordInviteResult.NotReachable val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } val wraps = client.fetchAll(filters = filters) - val bundle = wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } ?: return null + val bundle = wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } + if (bundle == null) { + // The filter matches only kind-33301 bundles authored by this link signer, so any wrap + // that came back IS a bundle we simply couldn't open — a wrong token or, as the reference + // client evolves, a bundle format newer than we support. Distinguish that from "nothing on + // any relay" so the UI skips the pointless retry on an incompatible link. + return if (wraps.isNotEmpty()) ConcordInviteResult.Incompatible else ConcordInviteResult.NotReachable + } val entry = ConcordCommunityListEntry( @@ -2064,7 +2075,7 @@ class Account( addedAt = TimeUtils.now() * 1000, ) joinConcordCommunity(entry) - return bundle.communityId + return ConcordInviteResult.Joined(bundle.communityId) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt new file mode 100644 index 0000000000..69a39a19c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -0,0 +1,50 @@ +/* + * 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.model + +/** + * The outcome of redeeming a Concord invite link (CORD-05). Separating the failure + * modes lets the UI tell the user *why* it failed and — crucially — whether + * retrying could ever help, so a link we can never open doesn't strand the user on + * an endless "redeeming…" spinner with a retry button that loops forever. + */ +sealed interface ConcordInviteResult { + /** Redeemed and joined; navigate to [communityId]. */ + data class Joined( + val communityId: String, + ) : ConcordInviteResult + + /** The link itself is malformed, or this account can't join (read-only key). Retrying can't help. */ + data object InvalidLink : ConcordInviteResult + + /** + * No invite bundle was reachable on any relay — a transient miss (relays down, + * link too new to have propagated, or expired). Retrying may help. + */ + data object NotReachable : ConcordInviteResult + + /** + * The bundle event was found but could not be opened with the link's token — + * typically because it was minted by a newer/incompatible Concord client whose + * bundle format this app can't read yet. Retrying can't help. + */ + data object Incompatible : ConcordInviteResult +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index 31c400395f..a9dbb85c1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.ConcordInviteResult import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -50,7 +51,15 @@ private sealed interface RedeemState { val communityId: String, ) : RedeemState - data object Failed : RedeemState + /** + * The redeem failed. [messageRes] explains why; [canRetry] is true only for a + * transient miss — an invalid or incompatible link can never succeed, so we don't + * offer a retry that would just loop back onto the same spinner. + */ + data class Failed( + val messageRes: Int, + val canRetry: Boolean, + ) : RedeemState } /** @@ -69,8 +78,16 @@ fun ConcordInviteScreen( LaunchedEffect(link, state) { if (state is RedeemState.Working) { - val communityId = accountViewModel.account.joinConcordViaInvite(link) - state = if (communityId != null) RedeemState.Done(communityId) else RedeemState.Failed + state = + when (val result = accountViewModel.account.joinConcordViaInvite(link)) { + is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId) + is ConcordInviteResult.InvalidLink -> + RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_invalid, canRetry = false) + is ConcordInviteResult.Incompatible -> + RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_incompatible, canRetry = false) + is ConcordInviteResult.NotReachable -> + RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed, canRetry = true) + } } } @@ -96,16 +113,19 @@ fun ConcordInviteScreen( } is RedeemState.Failed -> { + val failed = state as RedeemState.Failed Text( - stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_failed), + stringRes(failed.messageRes), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, ) - Button( - onClick = { state = RedeemState.Working }, - modifier = Modifier.padding(top = 16.dp), - ) { - Text(stringRes(com.vitorpamplona.amethyst.R.string.retry)) + if (failed.canRetry) { + Button( + onClick = { state = RedeemState.Working }, + modifier = Modifier.padding(top = 16.dp), + ) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.retry)) + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6c8d8cb12b..791e78738d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -309,6 +309,8 @@ Loading account Redeeming invite… Could not fetch this invite. The link may be expired or its relays unreachable. + This invite link is invalid or can\'t be opened with this account. + This invite was created with a newer version of the app and can\'t be opened here yet. Ask for an updated link or try again after updating. Concord Channels You haven\'t joined any Concord Channels yet. Create one, or open an invite link. No channels yet. From 6735c0b7a653ee0530155e16585a311ca69a43f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:10:31 +0000 Subject: [PATCH 21/45] fix(commons): import kotlinx.coroutines.IO for native Dispatchers.IO access `BlossomServerListState` used `Dispatchers.IO` without importing the common `kotlinx.coroutines.IO` accessor. On JVM/Android the JVM member resolved fine, but on Kotlin/Native (iosSimulatorArm64) it resolved to the internal `Dispatchers.IO` member and failed to compile: Cannot access 'val IO: CoroutineDispatcher': it is internal in 'kotlinx.coroutines.Dispatchers'. Add the missing `import kotlinx.coroutines.IO`, matching every other common-source state holder in this module. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QtPgFEy9AxoaUT5AqEFBZX --- .../commons/model/nipB7Blossom/BlossomServerListState.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt index 2d13cd22fe..4bec8035ec 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn From ae9f4a0def9ea634d461d497bc1a101abbceaa52 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:24:37 +0000 Subject: [PATCH 22/45] fix(concord): resolve invite coordinate per CORD-05 (honor revocation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up making the invite redeemer match the CORD-05 §2 spec for the addressable invite coordinate (33301, link_signer, d=""): - vsk=6 → live bundle (open with the link token) - vsk=9 → revocation tombstone: the newest event wins, so a tombstone buries even a stale, still-openable copy on another relay ("a fetcher finds the grave instead of keys"). Amethyst previously never checked for this, so a revoked link failed generically. - anything else present (e.g. a mis-posted registry vsk=8, the shape of the relayop.xyz link that hung) → unreadable - nothing on any relay → absent New pure `ConcordInviteBundle.classify(wraps, token): InviteBundleStatus` in quartz (next to parse/validate), wrapped by `ConcordActions.classifyInvite`, and mapped by `Account.joinConcordViaInvite` to the `ConcordInviteResult` cases — including a new `Revoked` outcome with its own message and no futile retry. Crypto is unchanged and already matches the spec (hkdf(token,'concord/invite-key') → NIP-44 → snake_case CommunityInvite). Adds ConcordInviteClassifyTest covering live / revoked (order-independent) / unreadable / absent, plus the real relayop.xyz vsk=8 event → Unreadable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD --- .../vitorpamplona/amethyst/model/Account.kt | 20 +-- .../amethyst/model/ConcordInviteResult.kt | 6 + .../concord/ConcordInviteScreen.kt | 2 + amethyst/src/main/res/values/strings.xml | 1 + .../commons/actions/ConcordActions.kt | 12 ++ .../cord05Invites/ConcordInviteBundle.kt | 49 +++++++ .../ConcordInviteClassifyTest.kt | 123 ++++++++++++++++++ 7 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt 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 92bf6fb011..d506daf54a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -157,6 +157,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope @@ -2054,14 +2055,17 @@ class Account( val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } val wraps = client.fetchAll(filters = filters) - val bundle = wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } - if (bundle == null) { - // The filter matches only kind-33301 bundles authored by this link signer, so any wrap - // that came back IS a bundle we simply couldn't open — a wrong token or, as the reference - // client evolves, a bundle format newer than we support. Distinguish that from "nothing on - // any relay" so the UI skips the pointless retry on an incompatible link. - return if (wraps.isNotEmpty()) ConcordInviteResult.Incompatible else ConcordInviteResult.NotReachable - } + + // Resolve the coordinate per CORD-05 §2 (newest wins; a vsk=9 tombstone revokes even over a + // stale openable copy) so we honour revocation and can tell the user *why* a link won't open + // instead of stranding them on a spinner that retries a link we can never redeem. + val bundle = + when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { + is InviteBundleStatus.Live -> status.invite + InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked + InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible + InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable + } val entry = ConcordCommunityListEntry( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt index 69a39a19c1..6c9cc50222 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -41,6 +41,12 @@ sealed interface ConcordInviteResult { */ data object NotReachable : ConcordInviteResult + /** + * The link was revoked: the newest event at its coordinate is a `vsk=9` revocation + * tombstone (CORD-05 §2). Retrying can't help — the owner retired this link. + */ + data object Revoked : ConcordInviteResult + /** * The bundle event was found but could not be opened with the link's token — * typically because it was minted by a newer/incompatible Concord client whose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index a9dbb85c1e..8c592fb7e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -85,6 +85,8 @@ fun ConcordInviteScreen( RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_invalid, canRetry = false) is ConcordInviteResult.Incompatible -> RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_incompatible, canRetry = false) + is ConcordInviteResult.Revoked -> + RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_revoked, canRetry = false) is ConcordInviteResult.NotReachable -> RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed, canRetry = true) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 791e78738d..1dd3620c16 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -311,6 +311,7 @@ Could not fetch this invite. The link may be expired or its relays unreachable. This invite link is invalid or can\'t be opened with this account. This invite was created with a newer version of the app and can\'t be opened here yet. Ask for an updated link or try again after updating. + This invite link has been revoked and can no longer be used. Ask for a new one. Concord Channels You haven\'t joined any Concord Channels yet. Create one, or open an invite link. No channels yet. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index c975e232e0..8b6bbb6528 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent @@ -314,6 +315,17 @@ object ConcordActions { token: ByteArray, ): CommunityInvite? = ConcordInviteBundle.parse(bundleEvent, token)?.takeIf { ConcordInviteBundle.validate(it) } + /** + * Resolves every event fetched at an invite's addressable coordinate into one + * [InviteBundleStatus] (live / revoked / unreadable / absent) per CORD-05 §2, so a + * redeeming client honours a `vsk=9` revocation tombstone and reports why a link + * can't be opened instead of retrying blindly. + */ + fun classifyInvite( + wraps: List, + token: ByteArray, + ): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token) + /** Derives the control plane described by a redeemed [invite] so the joiner can read it. */ fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt index 88fc9e9702..6feab287b2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.quartz.concord.cord05Invites import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.Event @@ -31,6 +33,34 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.utils.RandomInstance +/** + * What the events fetched at an invite's addressable coordinate `(33301, + * link_signer, d="")` actually resolve to under CORD-05 §2. The coordinate is + * replaceable, so its live state is the **newest** event: a revocation tombstone + * (`vsk=9`) supersedes and buries the bundle even if an older openable copy still + * lingers on some relay, which is exactly the "fetcher finds the grave instead of + * keys" behaviour the spec mandates. + */ +sealed interface InviteBundleStatus { + /** A live `vsk=6` bundle that opened with the link token. */ + data class Live( + val invite: CommunityInvite, + ) : InviteBundleStatus + + /** The newest event at the coordinate is a `vsk=9` revocation tombstone — the link was retired. */ + data object Revoked : InviteBundleStatus + + /** + * Something is at the coordinate, but it isn't a `vsk=6` bundle this client can open — + * a wrong/expired token, or a sub-kind (e.g. a mis-posted registry `vsk=8`) or format + * newer than we support. + */ + data object Unreadable : InviteBundleStatus + + /** Nothing was found at the coordinate on any queried relay (unreachable, expired, or not yet propagated). */ + data object Absent : InviteBundleStatus +} + /** A freshly minted public invite link: the shareable URL, the link keys, and the bundle to publish. */ class MintedInviteLink( val url: String, @@ -82,6 +112,25 @@ object ConcordInviteBundle { } } + /** + * Resolves the events fetched at an invite's addressable coordinate into a single + * [InviteBundleStatus] under CORD-05 §2 replaceable semantics. The newest event + * wins: a `vsk=9` revocation tombstone marks the link [InviteBundleStatus.Revoked] + * even when an older, still-openable bundle is also present (so a stale relay copy + * can't resurrect a retired link). Otherwise the first `vsk=6` bundle that opens + + * validates with [token] is [InviteBundleStatus.Live]; anything else present is + * [InviteBundleStatus.Unreadable], and an empty set is [InviteBundleStatus.Absent]. + */ + fun classify( + wraps: List, + token: ByteArray, + ): InviteBundleStatus { + val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent + if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked + val invite = wraps.firstNotNullOfOrNull { parse(it, token)?.takeIf { i -> validate(i) } } + return if (invite != null) InviteBundleStatus.Live(invite) else InviteBundleStatus.Unreadable + } + /** * Validates that an [invite]'s owner + salt actually reproduce its * community_id (CORD-02 self-certification), so a bundle can't smuggle a false diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt new file mode 100644 index 0000000000..00d941ad25 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt @@ -0,0 +1,123 @@ +/* + * 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.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Resolving an invite's addressable coordinate per CORD-05 §2: a live `vsk=6` bundle + * opens, a `vsk=9` revocation tombstone wins even over a still-openable stale copy, + * an unknown/mis-posted sub-kind is unreadable, and an empty fetch is absent. + */ +class ConcordInviteClassifyTest { + private val owner = NostrSignerInternal(KeyPair()) + + private fun inviteFor(community: NewConcordCommunity) = + CommunityInvite( + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + communityRoot = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + ) + + /** A raw kind-33301 event at the link-signer coordinate carrying an arbitrary [vsk] wire value. */ + private fun coordinateEvent( + linkSignerPubKey: String, + vsk: String, + createdAt: Long, + content: String = "", + ) = Event( + id = "00".repeat(32), + pubKey = linkSignerPubKey, + createdAt = createdAt, + kind = ConcordInviteBundleEvent.KIND, + tags = arrayOf(arrayOf("d", ""), VskTag.TAG_NAME.let { arrayOf(it, vsk) }), + content = content, + sig = "00".repeat(64), + ) + + @Test + fun liveBundleOpens() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token) + assertTrue(status is InviteBundleStatus.Live) + assertEquals(community.communityIdHex, status.invite.communityId) + } + + @Test + fun revocationTombstoneWinsOverStaleBundle() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + // A newer vsk=9 tombstone at the same coordinate buries the still-openable bundle. + val tombstone = coordinateEvent(minted.linkSignerPubKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L) + + assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.token)) + // Order of the fetched list must not matter — newest createdAt wins regardless. + assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.token)) + } + + @Test + fun unknownSubKindIsUnreadable() = + runTest { + // A mis-posted registry (vsk=8) at the bundle coordinate — the exact shape of the + // relayop.xyz link that hung — is present but not a vsk=6 bundle we can open. + val registry = coordinateEvent("aa".repeat(32), ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable") + assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16))) + } + + @Test + fun emptyFetchIsAbsent() { + assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16))) + } + + @Test + fun realRelayopBundleIsUnreadable() { + // The actual kind-33301 event behind the reported relayop.xyz/invite link (vsk=8), plus the + // 16-byte token from its #fragment. It fetches fine but can't be opened, so a compliant + // redeemer reports Unreadable instead of spinning forever. + val json = + """{"content":"ApoDjyzcHUg2imEiqw6Gsfpc2O86r+CMtMor+jc8ZlgrYwlI6CCmX7qGGEQvEJ5537nINE9H09Ro8RtEghpYgwkhdPHS274RpklFmuyLMdcoC5u1EVhppu8BrlHZ0YBfw3GX1Ui0uwy3V/J+rvrYiLhdREmwlK39JAX8sZfzCUhVtDMCgLVy03dwdpTC1Kj/ZeZJTYhJ8qmaN2273jgBTno/bFLzJlYvbANss69Tg53mljcmdSyhMlZ8z1kuenm1zkrPO5yHvi//r25tXkXb580OCkWxTmEwFzo20ntMgFnVSwVRvLZelOZt++tMevqi2Z5asvDgG7RytHP/0vLxxPzmjH0No+nITsxcmDbEweoKvSSzoc/7DYzENmfmrLXgP2KU/eE6CpTcSNaedLVKbAu9XptdtV8ruZxHjVBh1wpOwXkETEdqqvbCiR4TCNWzqbmwRKJ+acvZLBxhXcpfqmRsolaATU4sZKLs4iu92YpMIuUDh2Pquu0Daiz/IGnVe7BPb7E/gSd9NBFIxds6Nk1DbP8XKMRtYmWdTforUPWZqdM4EOtt8AcNpALRmsbEF26Gyd6t4/81bQPh+7WhI97lR/KkdWtKxNjjJ4CoJLgceyHuwbxXnFR23IWhzvQpBY12MBeYOw9oizvEzEGhEqpUns6LkH2sUNRRXbneNNvVgCEk6BK7j6Dxi95mcGJDEtOW+coE1SjhnfrwjIsdJL7cUEyC5DHFKuvxUi0iw/1I6b3AfZV5+A1tssEE2dhDv8uw6B3/a5EfMURFDqSfmGw1btdPPJ3+yjo1yYu2BtbYa4U++GtaAJfmNPrsB9lm4YgXuwCCRSpI2+TR9H2ntWM2j3HVdXqOpg3kfX82o9KFndo2g+7vGrOAyfL1jcybluq7AxPEV6D5yBky82MjoMeS0vSM6ytYu+0jheWPwDVs/3iPTELHPeDXAZOaw76ISBvNsXcxHvFsSiZBguBr+ucZOUnazVRAYIsmm/WNcIJu+6tfbyupqFCo5wkus6lKN2RNYIH1SRIi163cdBDhTBOdZoI2WcDr+SSW2fHtZutk7fW5IkJvSuy5xlke+YW/u3uzvriAIRmVDtk/fKISKEnMj2G47JdGn6EiHf+2+XfUSuDiliJb62pPXWBupinbb9HEW0tuyPHYGACH0/GA/egr6KMgI6YSh+BWS8vniMRTkmouKCzL5Csvc+2txC9LrfodrMF2R3jFZ1nig0mYzTQ9HvhqA2Uc+YG06iZtRaU7KqH6fMZYzPbjrxVOliyXR2G6","created_at":1784122846,"id":"112701bc1541c10b92f5a105e2e1f1813e591936e20075ec6a53c8bb8d235d81","kind":33301,"pubkey":"7177ccb8e8786c152e4960765f03fbceb7419d36a26e693a6399319760e7fd30","sig":"80eb4b49d70d73d35c1026b9c06d0fab280787950b5df412ebe4cdd05fcacadb4d20419c4e732749ed2698186a321069b4329ebd93fe21d8594d7392bf6445e0","tags":[["d",""],["vsk","8"]]}""" + val event = Event.fromJson(json) + val token = "c0277c415fe2ecc901a22b2f23dca5bf".hexToByteArray() + assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), token)) + } +} From b3fce19ff76605bb60cd583143950fb84ccfe07f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:25:34 +0000 Subject: [PATCH 23/45] fix: persist desktop AUTH approvals for long relay URLs The desktop AUTH banner (" requires authentication to deliver this message") kept re-appearing on every challenge even after the user clicked Always or Never. Root cause: PreferencesAuthApprovalStore used the raw relay URL as the java.util.prefs.Preferences key. Preferences caps keys at MAX_KEY_LENGTH (80 chars) and throws IllegalArgumentException from put() for anything longer. Outbox-proxy relay URLs that embed an npub and a query string routinely exceed that (e.g. wss://filter.nostr.wine/npub1...?broadcast=true is 102 chars), so setScope threw. RelayAuthenticator swallows the exception, so the ALWAYS/BLOCKED grant was silently never persisted and the relay re-prompted on the next AUTH challenge. Fold any relay URL over MAX_KEY_LENGTH into a bounded sha256:-prefixed 64-char hex key. Short URLs are still stored verbatim so existing grants keep working. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015aJPf1AsPTdzdMYR6pZ4e2 --- .../auth/PreferencesAuthApprovalStore.kt | 35 ++++++- .../auth/PreferencesAuthApprovalStoreTest.kt | 91 +++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt index ff47f1d38d..a61d888450 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt @@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.desktop.auth import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalStore +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.sha256.sha256 import java.util.prefs.Preferences /** @@ -46,6 +48,16 @@ import java.util.prefs.Preferences * `ONCE` scope is never persisted — that's the in-memory contract enforced * by the [AuthApprovalStore] interface. This implementation only writes * `ALWAYS` and `BLOCKED`. + * + * **Key length:** `java.util.prefs.Preferences` caps keys at + * [Preferences.MAX_KEY_LENGTH] (80 chars) and throws `IllegalArgumentException` + * from [Preferences.put] for anything longer. Relay URLs routinely exceed that + * — e.g. an outbox-proxy URL that embeds an npub and a query string + * (`wss://filter.nostr.wine/npub1…?broadcast=true`, 100+ chars). Storing such a + * URL raw made [setScope] throw; the caller (`RelayAuthenticator`) swallows the + * exception, so the `ALWAYS` / `BLOCKED` grant was silently never persisted and + * the AUTH banner re-appeared on every challenge. [keyFor] folds any over-long + * URL into a bounded 64-char SHA-256 hex key to stay under the cap. */ class PreferencesAuthApprovalStore( private val accountPubKeyHex: String, @@ -55,8 +67,27 @@ class PreferencesAuthApprovalStore( "/com/vitorpamplona/amethyst/desktop/auth/$accountPubKeyHex", ) + /** + * The Preferences key for a relay URL, guaranteed to fit within + * [Preferences.MAX_KEY_LENGTH]. + * + * Short URLs are stored verbatim (readable, and backward-compatible with + * grants written before this fix). URLs at or over the limit are hashed to + * a `sha256:`-prefixed 64-char hex digest (71 chars total, under the 80 + * cap). The prefix keeps the hashed keyspace disjoint from raw relay URLs, + * which always start with `ws://` / `wss://`, so the two can never collide. + */ + private fun keyFor(relayUrl: NormalizedRelayUrl): String { + val url = relayUrl.url + return if (url.length <= Preferences.MAX_KEY_LENGTH) { + url + } else { + "sha256:" + sha256(url.encodeToByteArray()).toHexKey() + } + } + override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? { - val raw = node.get(relayUrl.url, null) ?: return null + val raw = node.get(keyFor(relayUrl), null) ?: return null return runCatching { AuthApprovalScope.valueOf(raw) }.getOrNull() } @@ -70,7 +101,7 @@ class PreferencesAuthApprovalStore( // upgrade to "until next clear()". return } - node.put(relayUrl.url, scope.name) + node.put(keyFor(relayUrl), scope.name) node.flush() } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt new file mode 100644 index 0000000000..6fa6506c48 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt @@ -0,0 +1,91 @@ +/* + * 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.desktop.auth + +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PreferencesAuthApprovalStoreTest { + // A distinct per-run account so the test never collides with a real user's + // stored grants under Preferences.userRoot(). + private val accountPubKeyHex = "test${System.nanoTime()}".padEnd(64, '0').take(64) + private val store = PreferencesAuthApprovalStore(accountPubKeyHex) + + @AfterTest + fun cleanup() = + runTest { + store.clear() + } + + @Test + fun shortRelayUrlRoundTrips() = + runTest { + val relay = NormalizedRelayUrl("wss://relay.example/") + store.setScope(relay, AuthApprovalScope.ALWAYS) + assertEquals(AuthApprovalScope.ALWAYS, store.getScope(relay)) + } + + @Test + fun overLongRelayUrlPersistsInsteadOfThrowing() = + runTest { + // 102 chars — over java.util.prefs.Preferences.MAX_KEY_LENGTH (80). + // Storing this raw threw "Key too long", the exception was swallowed + // upstream, and the grant silently never persisted -> the AUTH banner + // re-appeared on every challenge. Regression guard for that bug. + val relay = NormalizedRelayUrl("wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true") + assertEquals(102, relay.url.length) + + store.setScope(relay, AuthApprovalScope.ALWAYS) + assertEquals(AuthApprovalScope.ALWAYS, store.getScope(relay)) + } + + @Test + fun overLongRelayUrlBlockedPersists() = + runTest { + val relay = NormalizedRelayUrl("wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true") + store.setScope(relay, AuthApprovalScope.BLOCKED) + assertEquals(AuthApprovalScope.BLOCKED, store.getScope(relay)) + } + + @Test + fun distinctOverLongRelayUrlsDoNotCollide() = + runTest { + val a = NormalizedRelayUrl("wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true") + val b = NormalizedRelayUrl("wss://filter.nostr.wine/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq?broadcast=true") + store.setScope(a, AuthApprovalScope.ALWAYS) + store.setScope(b, AuthApprovalScope.BLOCKED) + assertEquals(AuthApprovalScope.ALWAYS, store.getScope(a)) + assertEquals(AuthApprovalScope.BLOCKED, store.getScope(b)) + } + + @Test + fun onceIsNeverPersisted() = + runTest { + val relay = NormalizedRelayUrl("wss://relay.example/") + store.setScope(relay, AuthApprovalScope.ONCE) + assertNull(store.getScope(relay)) + } +} From 01fbc34e5e9ff781c1889cba337a1c7d82ae993b Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:32:20 +0000 Subject: [PATCH 24/45] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-zh-rCN/strings.xml | 79 +++++++++++++++++-- docs/changelog/translators.json | 12 +-- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 448ad4565d..e66c06f5f6 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -325,6 +325,10 @@ %1$d 位成员 + + %1$d 条新消息 + + 尚无消息 创建 邀请其他人 邀请链接 @@ -395,7 +399,7 @@ 因此聊天信息会随着时间的推移而消失。 公共聊天 创建新的公开聊天 - Marmot 群 + Marmot 群组 尚无消息 公开聊天元数据 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: @@ -1842,25 +1846,62 @@ 开始对话 - 最适用于 + 最适合 好 折衷办法 私密 加密群组 - 识别中继 + 支持中继 私信 私信,端到端加密 1:1–5 个房间 和你认识的人的一对一及小型群聊。 新消息 1-1 & 小型群组 - 端到端加密的 + 端到端加密 稍后无法添加用户 Marmot 群 较大的私密群 绑定设备 规模较大的私密群组,不再适合小型群组私信。 创建群组 + 发送大量私信 + 加密的群组聊天 + 绑定设备-如果您在其他地方登录则不会显示 + Concord 社区 + 按频道拆分社区 + 工作区域 + 根据频道或工作流组织的大规模社区。 + 创建社区 + 扩大到超大社区 + 按照频道/工作流拆分 + 更多待设置 + 公开聊天 + 中继上的公开消息 + 无人管理 + 任何人都可以发帖的开放公共房间。 + 创建公开聊天 + 任何人都可以加入 + 简单 & 可发现 + 仅公开 + 无审核 + 中继群组 + 中继运行,公开或私密 + 有审核 + 群组由托管它们的中继管理。 + 浏览中继群组 + 由中继管理 + 公开或私密 + 绑定单一中继 + 临时聊天 + 不论谁在线,立马聊天 + 现在在线 + 与当前在线的任何人进行即时聊天。 + 开始聊天 + 与当前在线的人交谈 + 不保存任何内容 + 没有历史记录 + 限于中继范围 中继组 行内 根据中继 @@ -1905,6 +1946,10 @@ 成员 编辑群 主题帖 + 固定消息 + 取消固定消息 + 已固定 + 固定的消息 打开群组 此中继上尚没有群。 这个中继不广播群组支持 (NIP-29),所以它可能不托管任何群组。 @@ -2550,8 +2595,23 @@ 底部导航栏 拖动可重新排序。切换可从底部栏中添加或删除一个项目。无项目则底部栏隐藏。 可用 + 您的底部栏 + 没有固定的项目。在您添加至少一个项目前不显示底部栏。 调整顺序 + 显示选项 + 尚无收藏内容。在浏览器中给应用添加星标以在此添加。 + 尚未加入任何群组。 + 拖动以重新排序 · 点击✕删除 + 添加 + 已添加 + 删除 恢复默认 + 主要 + 聊天 & 群组 + 您 + 订阅源 + Apps & Web + 其它 主页标签 选择主屏幕上出现的标签页。当只有一个标签处于活动状态时,标签栏将被隐藏。 全部 @@ -2621,6 +2681,15 @@ 移除地点 添加内容警告 移除内容警告 + %1$s 创建了频道 %2$s + %1$s 创建了频道 + %1$s 更新了频道配置文件 + 消息传递 + 关闭 + 正在等待中继接受此消息 + 没有此消息的中继信息 + 被至少一个中继接受 + 已传送到所有收信人的中继 添加过期日期 删除过期日期 过期日期 @@ -2966,7 +3035,7 @@ 已运行后台任务 中继重连(失败的) Web 请求 - 下载的广播唤醒数(预估) + 为下载进行的广播唤醒数(预估) 有效传输时间 媒体播放时间 签名已验证 diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index b56145cbb7..69409e1e16 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -134,18 +134,18 @@ "Hindi" ] }, - { - "user": "summoner001", - "languages": [ - "Hungarian" - ] - }, { "user": "hypnotichemionus4", "languages": [ "Chinese Simplified" ] }, + { + "user": "summoner001", + "languages": [ + "Hungarian" + ] + }, { "user": "StellarStoic", "languages": [ From 72a70ab6352c21cd1e5792190b4a85fbb2e9d98e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:38:54 +0000 Subject: [PATCH 25/45] test: make AUTH approval key test OS-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous regression test drove PreferencesAuthApprovalStore against the real java.util.prefs.Preferences.userRoot() backing store, which threw IllegalArgumentException on the macOS CI runner (its preferences backend rejects operations the Linux one accepts). Extract the key derivation into a pure `authApprovalPreferenceKey` function and assert the invariant that actually matters — every derived key stays within Preferences.MAX_KEY_LENGTH, which is exactly the condition put() enforces. No OS I/O, so it runs deterministically on every platform. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015aJPf1AsPTdzdMYR6pZ4e2 --- .../auth/PreferencesAuthApprovalStore.kt | 45 ++++---- .../auth/PreferencesAuthApprovalStoreTest.kt | 109 +++++++++--------- 2 files changed, 80 insertions(+), 74 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt index a61d888450..e059bc9dbc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt @@ -67,27 +67,8 @@ class PreferencesAuthApprovalStore( "/com/vitorpamplona/amethyst/desktop/auth/$accountPubKeyHex", ) - /** - * The Preferences key for a relay URL, guaranteed to fit within - * [Preferences.MAX_KEY_LENGTH]. - * - * Short URLs are stored verbatim (readable, and backward-compatible with - * grants written before this fix). URLs at or over the limit are hashed to - * a `sha256:`-prefixed 64-char hex digest (71 chars total, under the 80 - * cap). The prefix keeps the hashed keyspace disjoint from raw relay URLs, - * which always start with `ws://` / `wss://`, so the two can never collide. - */ - private fun keyFor(relayUrl: NormalizedRelayUrl): String { - val url = relayUrl.url - return if (url.length <= Preferences.MAX_KEY_LENGTH) { - url - } else { - "sha256:" + sha256(url.encodeToByteArray()).toHexKey() - } - } - override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? { - val raw = node.get(keyFor(relayUrl), null) ?: return null + val raw = node.get(authApprovalPreferenceKey(relayUrl), null) ?: return null return runCatching { AuthApprovalScope.valueOf(raw) }.getOrNull() } @@ -101,7 +82,7 @@ class PreferencesAuthApprovalStore( // upgrade to "until next clear()". return } - node.put(keyFor(relayUrl), scope.name) + node.put(authApprovalPreferenceKey(relayUrl), scope.name) node.flush() } @@ -110,3 +91,25 @@ class PreferencesAuthApprovalStore( node.flush() } } + +/** + * The Preferences key for a relay URL, guaranteed to fit within + * [Preferences.MAX_KEY_LENGTH]. + * + * Short URLs are stored verbatim (readable, and backward-compatible with grants + * written before this fix). URLs over the limit are hashed to a + * `sha256:`-prefixed 64-char hex digest (71 chars total, under the 80 cap). The + * prefix keeps the hashed keyspace disjoint from raw relay URLs, which always + * start with `ws://` / `wss://`, so the two can never collide. + * + * Pure and side-effect-free so it can be unit-tested without touching the OS + * Preferences backing store. + */ +internal fun authApprovalPreferenceKey(relayUrl: NormalizedRelayUrl): String { + val url = relayUrl.url + return if (url.length <= Preferences.MAX_KEY_LENGTH) { + url + } else { + "sha256:" + sha256(url.encodeToByteArray()).toHexKey() + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt index 6fa6506c48..b17d737b5c 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt @@ -20,72 +20,75 @@ */ package com.vitorpamplona.amethyst.desktop.auth -import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import kotlinx.coroutines.test.runTest -import kotlin.test.AfterTest +import java.util.prefs.Preferences import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +/** + * Regression tests for the AUTH-banner-repeats bug. + * + * `PreferencesAuthApprovalStore` used the raw relay URL as the + * `java.util.prefs.Preferences` key. Preferences caps keys at + * [Preferences.MAX_KEY_LENGTH] (80 chars) and `put()` throws + * `IllegalArgumentException` for anything longer. Outbox-proxy relay URLs that + * embed an npub and a query string routinely exceed that, so the `ALWAYS` / + * `BLOCKED` grant silently failed to persist and the banner re-appeared on + * every AUTH challenge. + * + * These assert the key-derivation invariant directly (no OS Preferences I/O, so + * they run deterministically on every platform): every key stays within the + * cap, which is exactly the condition `Preferences.put` enforces. + */ class PreferencesAuthApprovalStoreTest { - // A distinct per-run account so the test never collides with a real user's - // stored grants under Preferences.userRoot(). - private val accountPubKeyHex = "test${System.nanoTime()}".padEnd(64, '0').take(64) - private val store = PreferencesAuthApprovalStore(accountPubKeyHex) - - @AfterTest - fun cleanup() = - runTest { - store.clear() - } + // 102 chars — the outbox-proxy URL from the bug report, over the 80 cap. + private val longUrl = "wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true" @Test - fun shortRelayUrlRoundTrips() = - runTest { - val relay = NormalizedRelayUrl("wss://relay.example/") - store.setScope(relay, AuthApprovalScope.ALWAYS) - assertEquals(AuthApprovalScope.ALWAYS, store.getScope(relay)) - } + fun shortUrlStoredVerbatim() { + val url = "wss://relay.example/" + assertEquals(url, authApprovalPreferenceKey(NormalizedRelayUrl(url))) + } @Test - fun overLongRelayUrlPersistsInsteadOfThrowing() = - runTest { - // 102 chars — over java.util.prefs.Preferences.MAX_KEY_LENGTH (80). - // Storing this raw threw "Key too long", the exception was swallowed - // upstream, and the grant silently never persisted -> the AUTH banner - // re-appeared on every challenge. Regression guard for that bug. - val relay = NormalizedRelayUrl("wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true") - assertEquals(102, relay.url.length) + fun overLongUrlIsHashedUnderTheCap() { + assertEquals(102, longUrl.length) + val key = authApprovalPreferenceKey(NormalizedRelayUrl(longUrl)) - store.setScope(relay, AuthApprovalScope.ALWAYS) - assertEquals(AuthApprovalScope.ALWAYS, store.getScope(relay)) - } + assertTrue(key.startsWith("sha256:"), "over-long URLs must fold to a hashed key") + assertTrue( + key.length <= Preferences.MAX_KEY_LENGTH, + "key length ${key.length} must stay within Preferences.MAX_KEY_LENGTH so put() never throws", + ) + } @Test - fun overLongRelayUrlBlockedPersists() = - runTest { - val relay = NormalizedRelayUrl("wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true") - store.setScope(relay, AuthApprovalScope.BLOCKED) - assertEquals(AuthApprovalScope.BLOCKED, store.getScope(relay)) + fun everyKeyStaysWithinThePreferencesCap() { + // Boundary + well over: whatever the URL length, the derived key must be + // storable (this is the invariant Preferences.put enforces). + listOf( + "wss://relay.example/", + "wss://" + "a".repeat(Preferences.MAX_KEY_LENGTH), // 86 chars + "wss://" + "a".repeat(500), + longUrl, + ).forEach { url -> + val key = authApprovalPreferenceKey(NormalizedRelayUrl(url)) + assertTrue( + key.length <= Preferences.MAX_KEY_LENGTH, + "key for a ${url.length}-char URL was ${key.length} chars, over the ${Preferences.MAX_KEY_LENGTH} cap", + ) } + } @Test - fun distinctOverLongRelayUrlsDoNotCollide() = - runTest { - val a = NormalizedRelayUrl("wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true") - val b = NormalizedRelayUrl("wss://filter.nostr.wine/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq?broadcast=true") - store.setScope(a, AuthApprovalScope.ALWAYS) - store.setScope(b, AuthApprovalScope.BLOCKED) - assertEquals(AuthApprovalScope.ALWAYS, store.getScope(a)) - assertEquals(AuthApprovalScope.BLOCKED, store.getScope(b)) - } - - @Test - fun onceIsNeverPersisted() = - runTest { - val relay = NormalizedRelayUrl("wss://relay.example/") - store.setScope(relay, AuthApprovalScope.ONCE) - assertNull(store.getScope(relay)) - } + fun distinctOverLongUrlsProduceDistinctKeys() { + val a = "wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true" + val b = "wss://filter.nostr.wine/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq?broadcast=true" + assertNotEquals( + authApprovalPreferenceKey(NormalizedRelayUrl(a)), + authApprovalPreferenceKey(NormalizedRelayUrl(b)), + ) + } } From e24eec9ca6c141c86b5645dc978f4fc853860730 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:51:40 +0000 Subject: [PATCH 26/45] feat(nip29): subgroup hierarchy (parent/child) for relay groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the NIP-29 Subgroups feature merged upstream: groups can now be organized into a parent/child tree, scoped per host relay. Quartz: - Add `parent`/`child` tag classes and TagArray (builder) helpers. - GroupMetadataEvent (39000): parent()/children()/isRoot() accessors and build params. - EditMetadataEvent (9002): parent()/children() accessors and build params (a 9002 re-carries the full child list, per spec, or the relay rejects it). - SubgroupTree: assembles a relay's flat 39000 set into the hierarchy — structure follows each group's parent tag, sibling order follows the parent's child-tag order, orphans surface as roots, and malformed cycles are broken rather than looping. - NIP-11: advertise/detect subgroup support via `nip29: { subgroups: true }`, with a `subgroups()` builder DSL helper. - Tests for tag round-trips, tree assembly, ordering, orphans and cycles, plus NIP-11 serialization. Amethyst: - RelayGroupChannel: parentGroupId()/childGroupIds()/isSubgroup() reading the latest metadata. - Account.editRelayGroupMetadata: preserve the group's current parent and full children list on a plain metadata edit so an admin renaming a subgroup no longer detaches it (or gets rejected for dropping children). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S --- .../vitorpamplona/amethyst/model/Account.kt | 15 +- .../nip29RelayGroups/RelayGroupChannel.kt | 9 + .../nip11RelayInfo/Nip11RelayInformation.kt | 12 ++ .../Nip11RelayInformationBuilder.kt | 6 + .../quartz/nip29RelayGroups/SubgroupTree.kt | 102 +++++++++ .../metadata/GroupMetadataEvent.kt | 22 ++ .../moderation/EditMetadataEvent.kt | 14 ++ .../moderation/TagArrayBuilderExt.kt | 8 + .../moderation/TagArrayExt.kt | 8 + .../quartz/nip29RelayGroups/tags/ChildTag.kt | 48 +++++ .../quartz/nip29RelayGroups/tags/ParentTag.kt | 46 ++++ .../Nip11RelayInformationBuilderTest.kt | 22 ++ .../quartz/nip29RelayGroups/SubgroupTest.kt | 196 ++++++++++++++++++ 13 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt 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 ab22669284..88c531f1a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2811,7 +2811,16 @@ class Account( signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } - /** Edit the group's relay-signed metadata with a kind 9002 event (admin only). */ + /** + * Edit the group's relay-signed metadata with a kind 9002 event (admin only). + * + * NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy + * links: a 9002 with no `parent` tag re-roots the group, and one that drops any + * existing `child` is rejected by the relay. So unless the caller is explicitly + * re-parenting, we re-carry the group's current [parent] and full [children] list + * from its latest known metadata to keep the tree intact across a plain name/flag + * edit. Pass an explicit value to change them. + */ suspend fun editRelayGroupMetadata( channel: RelayGroupChannel, name: String?, @@ -2823,6 +2832,8 @@ class Account( isRestricted: Boolean, hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = channel.parentGroupId(), + children: List = channel.childGroupIds(), ) { val template = EditMetadataEvent.build( @@ -2833,6 +2844,8 @@ class Account( status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), hashtags = hashtags, geohashes = geohashes, + parent = parent, + children = children, ) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index aa702e13c5..8c6efff29f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -137,6 +137,15 @@ class RelayGroupChannel( fun hasLivekit(): Boolean = event?.hasLivekit() ?: false + /** Subgroups: the id of this group's parent on the same host relay, or null when it's a root. */ + fun parentGroupId(): String? = event?.parent() + + /** Subgroups: the ordered ids of this group's direct children (empty when it has none). */ + fun childGroupIds(): List = event?.children() ?: emptyList() + + /** Whether this group sits under a parent group (i.e. it is a subgroup). */ + fun isSubgroup(): Boolean = event?.isRoot() == false + fun updateGroupInfo( event: GroupMetadataEvent, eventNote: Note? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 552e5f6afc..e2b9465bb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -53,6 +53,7 @@ data class Nip11RelayInformation( val fees: RelayInformationFees? = null, val nip50: List? = null, val supported_grasps: List? = null, + val nip29: Nip29Support? = null, ) { /** * Serializes this document to JSON for serving at the relay's root over the @@ -67,6 +68,17 @@ data class Nip11RelayInformation( fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson(json) } + /** + * NIP-29 relay capability advertisement. A relay that supports the subgroup + * hierarchy sets `nip29: { "subgroups": true }` in its NIP-11 document so + * clients know they can offer parent/child grouping for this relay's groups. + */ + @Stable + @Serializable + data class Nip29Support( + val subgroups: Boolean? = null, + ) + @Stable @Serializable data class RelayInformationFee( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt index 8a5fbcd460..54d77d8674 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip11RelayInfo import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.Nip29Support import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFee import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFees import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationLimitation @@ -87,6 +88,7 @@ class Nip11RelayInformationBuilder { private var limitation: RelayInformationLimitation? = null private var fees: RelayInformationFees? = null + private var nip29: Nip29Support? = null /** Advertise supported NIP numbers, e.g. `supports(1, 11, 42, 50)`. Repeatable. */ fun supports(vararg nips: Int) = apply { nips.forEach { supportedNips.add(it.toString()) } } @@ -112,6 +114,9 @@ class Nip11RelayInformationBuilder { /** GRASP git-server capabilities the relay implements (`supported_grasps`). Repeatable. */ fun grasps(vararg values: String) = apply { supportedGrasps.addAll(values) } + /** Advertise NIP-29 subgroup support (`nip29: { "subgroups": true }`). */ + fun subgroups(supported: Boolean = true) = apply { nip29 = Nip29Support(subgroups = supported) } + /** Declare the relay's `limitation` object via a nested DSL. */ fun limitation(initializer: LimitationBuilder.() -> Unit) = apply { @@ -165,6 +170,7 @@ class Nip11RelayInformationBuilder { fees = fees, nip50 = nip50Subfeatures.ifEmpty { null }?.toList(), supported_grasps = supportedGrasps.ifEmpty { null }?.toList(), + nip29 = nip29, ) @Nip11DslMarker diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt new file mode 100644 index 0000000000..af3e53593e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTree.kt @@ -0,0 +1,102 @@ +/* + * 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.nip29RelayGroups + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent + +/** A group and its (recursively assembled) subgroups. */ +@Immutable +data class GroupTreeNode( + val metadata: GroupMetadataEvent, + val children: List, +) + +/** + * Assembles the NIP-29 subgroup hierarchy from a flat set of `kind:39000` + * metadata events (all from the same relay — the tree is relay-scoped). + * + * Structure follows each group's own `parent` tag, which is the single source of + * truth for who a group's parent is (a group carries at most one `parent`, so a + * group can never end up under two parents). A parent's advertised `child` tag + * order is used only to order siblings; children a parent hasn't listed yet are + * appended after the listed ones in a stable order. + * + * Robustness rules, matching the spec's relay behaviour: + * - A group whose declared parent is not present in the set is treated as a root + * (the spec has relays reject such edits, but a client aggregating a partial + * view must still show the group rather than drop it). + * - Cycles cannot occur on a compliant relay (it rejects any `kind:9002` that + * would create one), but if malformed data produces one it is broken and the + * involved groups surface as roots rather than looping forever. + * - When multiple `kind:39000` events share a `d` id the newest wins. + */ +object SubgroupTree { + fun build(events: Collection): List { + val byId = LinkedHashMap() + events.forEach { event -> + val id = event.groupId().ifEmpty { return@forEach } + val existing = byId[id] + if (existing == null || event.createdAt >= existing.createdAt) byId[id] = event + } + + val childrenOf = HashMap>() + byId.values.forEach { event -> + val parent = event.parent() + if (parent != null && parent in byId) { + childrenOf.getOrPut(parent) { mutableListOf() }.add(event) + } + } + + val placed = HashSet() + + fun node(event: GroupMetadataEvent): GroupTreeNode { + val id = event.groupId() + placed.add(id) + + // Order siblings by the parent's advertised `child` tag order; anything + // the parent hasn't listed keeps its natural order after the listed ones. + val order = event.children().withIndex().associate { (index, childId) -> childId to index } + val orderedChildren = + childrenOf[id] + .orEmpty() + .filter { it.groupId() !in placed } // cycle guard + .sortedBy { order[it.groupId()] ?: Int.MAX_VALUE } + + return GroupTreeNode(event, orderedChildren.map { node(it) }) + } + + val roots = + byId.values.filter { + val parent = it.parent() + parent == null || parent !in byId + } + + val tree = roots.map { node(it) }.toMutableList() + + // Safety net: any group left unplaced was part of a cycle — surface it as a root. + byId.values.forEach { event -> + if (event.groupId() !in placed) tree.add(node(event)) + } + + return tree + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index 618c41fab1..b708ca5b24 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -83,6 +85,22 @@ class GroupMetadataEvent( /** Group supports LiveKit-powered live audio/video. Presence of the `livekit` flag. */ fun hasLivekit() = tags.hasTagName("livekit") + /** + * Subgroups: the id of this group's parent, or null when this is a root group. + * At most one `parent` tag is expected (NIP-29 §Subgroups). + */ + fun parent(): String? = tags.firstNotNullOfOrNull(ParentTag::parse) + + /** + * Subgroups: the ordered ids of this group's direct children. The position of + * each `child` tag in the array is the intended display order. Empty when the + * group has no children (or the relay doesn't advertise them). + */ + fun children(): List = tags.mapNotNull(ChildTag::parse) + + /** A group with no `parent` tag is a root group in the subgroup tree. */ + fun isRoot(): Boolean = parent() == null + /** * The kinds this group accepts, when constrained, e.g. `["supported_kinds", "9", "11"]`. * `null` (tag absent) means all kinds are accepted. @@ -127,6 +145,8 @@ class GroupMetadataEvent( supportedKinds: List? = null, hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = null, + children: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -141,6 +161,8 @@ class GroupMetadataEvent( addAll(HashtagTag.assemble(hashtags)) // Mip-map each geohash into every prefix so a coarser followed geohash still matches. geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } + parent?.let { add(ParentTag.assemble(it)) } + addAll(ChildTag.assemble(children)) initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt index 00695e733e..df9fa03c92 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -54,6 +54,16 @@ class EditMetadataEvent( fun geohashes() = tags.geohashes() + /** Subgroups: the requested parent group id, or null to (re-)root this group. */ + fun parent() = tags.parentGroupId() + + /** + * Subgroups: the ordered child ids carried on this edit. Per NIP-29 a metadata + * edit of a parent group MUST re-list all of its children, so the relay rejects + * a `kind:9002` that drops any of them. + */ + fun children() = tags.childGroupIds() + fun previousEvents() = tags.previousEvents() override fun indexableContent() = listOfNotNull(name(), about()).joinToString("\n") @@ -69,6 +79,8 @@ class EditMetadataEvent( status: Set = emptySet(), hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = null, + children: List = emptyList(), previousEvents: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, @@ -81,6 +93,8 @@ class EditMetadataEvent( addAll(HashtagTag.assemble(hashtags)) // Mip-map each geohash into every prefix so a coarser followed geohash still matches. geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } + parent?.let { parentGroup(it) } + childGroups(children) previous(previousEvents) initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt index 00e5fe2f68..89e095edc2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayBuilderExt.kt @@ -23,14 +23,22 @@ package com.vitorpamplona.quartz.nip29RelayGroups.moderation import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag fun TagArrayBuilder.groupId(groupId: String) = addUnique(GroupIdTag.assemble(groupId)) fun TagArrayBuilder.previous(eventIdPrefixes: List) = addAll(PreviousTag.assemble(eventIdPrefixes)) +/** Sets the subgroup `parent` tag (the parent group's id). At most one per event. */ +fun TagArrayBuilder.parentGroup(parentGroupId: String) = addUnique(ParentTag.assemble(parentGroupId)) + +/** Appends the ordered `child` subgroup tags. */ +fun TagArrayBuilder.childGroups(childGroupIds: List) = addAll(ChildTag.assemble(childGroupIds)) + fun TagArrayBuilder.userPubKey(pubKey: HexKey) = add(arrayOf("p", pubKey)) fun TagArrayBuilder.userPubKeyWithRoles( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt index 32115a9b7c..e3cc924fc2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt @@ -25,14 +25,22 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME) fun TagArray.previousEvents() = mapNotNull(PreviousTag::parse) +/** The `parent` group id (subgroups), or null when this is a root group. At most one is expected. */ +fun TagArray.parentGroupId() = firstNotNullOfOrNull(ParentTag::parse) + +/** The ordered list of direct `child` subgroup ids advertised on a parent's metadata. */ +fun TagArray.childGroupIds(): List = mapNotNull(ChildTag::parse) + fun TagArray.userPubKeys(): List = mapNotNull(PTag::parseKey) fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt new file mode 100644 index 0000000000..0f934a41e3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ChildTag.kt @@ -0,0 +1,48 @@ +/* + * 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.nip29RelayGroups.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-29 subgroups `child` tag. A parent group's `kind:39000` metadata carries one + * `child` tag per direct subgroup, pointing at the child's `d` identifier. The + * order of the `child` tags in the array is the display order of the children; the + * relay appends new children as they are created and an admin can reorder them via + * a `kind:9002` carrying the full desired `child` list. + */ +class ChildTag { + companion object { + const val TAG_NAME = "child" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(childGroupId: String) = arrayOf(TAG_NAME, childGroupId) + + fun assemble(childGroupIds: List) = childGroupIds.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt new file mode 100644 index 0000000000..dd724de792 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/tags/ParentTag.kt @@ -0,0 +1,46 @@ +/* + * 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.nip29RelayGroups.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-29 subgroups `parent` tag. Points at the parent group's `d` identifier and + * appears on the subgroup's own `kind:39000` metadata (and on the `kind:9002` + * edit-metadata request that sets it). A group with no `parent` tag is a root. + * + * The spec allows at most one `parent` tag per group. + */ +class ParentTag { + companion object { + const val TAG_NAME = "parent" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(parentGroupId: String) = arrayOf(TAG_NAME, parentGroupId) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt index 621b4aef9e..8a9c930807 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt @@ -147,6 +147,28 @@ class Nip11RelayInformationBuilderTest { assertEquals(3600, info.retention?.get(1)?.time) } + @Test + fun advertisesNip29SubgroupSupport() { + val info = + relayInformation { + name = "Groups" + supports(29) + subgroups() + } + + assertEquals(true, info.nip29?.subgroups) + val json = info.toJson() + assertTrue(json.contains("\"nip29\":{\"subgroups\":true}"), json) + assertEquals(info, Nip11RelayInformation.fromJson(json)) + } + + @Test + fun omitsNip29WhenNotDeclared() { + val info = relayInformation { name = "R" } + assertNull(info.nip29) + assertTrue(!info.toJson().contains("nip29"), info.toJson()) + } + @Test fun listHelpersAreRepeatableAndCollapseWhenEmpty() { val info = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt new file mode 100644 index 0000000000..cc4360e828 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt @@ -0,0 +1,196 @@ +/* + * 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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * NIP-29 §Subgroups: the `parent`/`child` tags on `kind:39000` group metadata and + * `kind:9002` edit-metadata, plus [SubgroupTree] assembly of a relay's flat group + * set into the parent/child hierarchy (ordering, orphan-as-root, cycle safety). + */ +class SubgroupTest { + private val relaySelf = "aa".repeat(32) + private val sig = "bb".repeat(64) + private val id = "00".repeat(32) + + private fun metadata( + groupId: String, + parent: String? = null, + children: List = emptyList(), + createdAt: Long = 100, + ): GroupMetadataEvent { + val template = GroupMetadataEvent.build(groupId, name = groupId, parent = parent, children = children, createdAt = createdAt) + return EventFactory.create(id, relaySelf, template.createdAt, GroupMetadataEvent.KIND, template.tags, "", sig) as GroupMetadataEvent + } + + @Test + fun rootGroupHasNoParent() { + val root = metadata("tech", children = listOf("nostr")) + assertNull(root.parent()) + assertTrue(root.isRoot()) + assertEquals(listOf("nostr"), root.children()) + } + + @Test + fun subgroupCarriesParentAndChildOrder() { + val sub = metadata("nostr", parent = "tech", children = listOf("nip29", "nips")) + assertEquals("tech", sub.parent()) + assertEquals(false, sub.isRoot()) + assertEquals(listOf("nip29", "nips"), sub.children()) + } + + @Test + fun editMetadataRoundTripsParentAndChildren() { + val template = EditMetadataEvent.build("nostr", name = "Nostr", parent = "social", children = listOf("nip29")) + val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent + + assertEquals("nostr", event.groupId()) + assertEquals("social", event.parent()) + assertEquals(listOf("nip29"), event.children()) + } + + @Test + fun editMetadataWithoutParentRoots() { + val template = EditMetadataEvent.build("nostr", name = "Nostr") + val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent + assertNull(event.parent()) + } + + @Test + fun buildsNestedTree() { + val tree = + SubgroupTree.build( + listOf( + metadata("tech", children = listOf("nostr")), + metadata("nostr", parent = "tech", children = listOf("nip29")), + metadata("nip29", parent = "nostr"), + ), + ) + + assertEquals(1, tree.size) + val tech = tree[0] + assertEquals("tech", tech.metadata.groupId()) + assertEquals(1, tech.children.size) + val nostr = tech.children[0] + assertEquals("nostr", nostr.metadata.groupId()) + assertEquals( + "nip29", + nostr.children + .single() + .metadata + .groupId(), + ) + } + + @Test + fun ordersSiblingsByParentChildTags() { + // Parent lists children in b, a, c order — the tree must follow that, not insertion order. + val tree = + SubgroupTree.build( + listOf( + metadata("root", children = listOf("b", "a", "c")), + metadata("a", parent = "root"), + metadata("b", parent = "root"), + metadata("c", parent = "root"), + ), + ) + + assertEquals(listOf("b", "a", "c"), tree.single().children.map { it.metadata.groupId() }) + } + + @Test + fun unlistedChildrenComeAfterListedOnes() { + val tree = + SubgroupTree.build( + listOf( + metadata("root", children = listOf("a")), + metadata("a", parent = "root"), + // "b" points at root but root hasn't listed it yet + metadata("b", parent = "root"), + ), + ) + + assertEquals(listOf("a", "b"), tree.single().children.map { it.metadata.groupId() }) + } + + @Test + fun groupWithMissingParentBecomesRoot() { + // "nostr" declares a parent that isn't in the set — surface it as a root, not dropped. + val tree = SubgroupTree.build(listOf(metadata("nostr", parent = "ghost"))) + assertEquals(listOf("nostr"), tree.map { it.metadata.groupId() }) + } + + @Test + fun multipleRootsAreReturned() { + val tree = + SubgroupTree.build( + listOf( + metadata("tech"), + metadata("food"), + metadata("pizza", parent = "food"), + ), + ) + + assertEquals(setOf("tech", "food"), tree.map { it.metadata.groupId() }.toSet()) + } + + @Test + fun cycleDoesNotLoopForever() { + // Malformed data: a <-> b point at each other. A compliant relay rejects this, + // but the assembler must terminate and still surface the groups. + val tree = + SubgroupTree.build( + listOf( + metadata("a", parent = "b"), + metadata("b", parent = "a"), + ), + ) + + val allIds = mutableSetOf() + + fun collect(node: GroupTreeNode) { + allIds.add(node.metadata.groupId()) + node.children.forEach(::collect) + } + tree.forEach(::collect) + assertTrue(allIds.containsAll(setOf("a", "b"))) + } + + @Test + fun newestMetadataWinsForSameId() { + val tree = + SubgroupTree.build( + listOf( + metadata("g", children = listOf("old"), createdAt = 100), + metadata("g", children = listOf("new"), createdAt = 200), + ), + ) + + assertEquals(listOf("new"), tree.single().metadata.children()) + } +} From abc673228651f6a70f15ec15d615115462b0d361 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:51:48 +0000 Subject: [PATCH 27/45] feat: support NIP-51 mute-list hashtag ("t") entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-51's kind:10000 mute list defines four entry types — `p` (pubkeys), `word`, `e` (threads) and `t` (hashtags). Quartz parsed only the first three, so `t` hashtag mutes written by other clients were silently dropped: uncounted, invisible, and never applied to filtering. Quartz: - Add HashtagTag (`"t"`) implementing the MuteTag sealed interface, and register it in MuteTag.parse/isTagged so it round-trips like the other entry types. - Add mutedHashtags()/mutedHashtagIds() TagArray helpers. Filtering (commons): - Add hiddenHashtags to LiveHiddenUsers plus isHashtagHidden(), and hide notes carrying a muted hashtag in Note.isHiddenFor() (exact, case- insensitive `t`-tag match — distinct from the existing substring word scan). Amethyst: - Aggregate HashtagTag entries from the mute/block lists in HiddenUsersState. - MuteListState.hideHashtag/showHashtag + Account and AccountViewModel wrappers, and observeUserIsMutingHashtag. - Surface a Mute/Unmute hashtag action in the hashtag screen's options overflow menu. Tests: HashtagTagTest (parse/round-trip/MuteTag dispatch) and NoteIsHiddenForTest cases for muted-hashtag hiding. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8 --- .../vitorpamplona/amethyst/model/Account.kt | 8 ++ .../model/nip51Lists/HiddenUsersState.kt | 3 + .../nip51Lists/muteList/MuteListState.kt | 34 ++++++++ .../reqCommand/user/UserObservers.kt | 27 +++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 4 + .../screen/loggedIn/hashtag/HashtagScreen.kt | 65 +++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + .../amethyst/commons/model/IAccount.kt | 4 + .../amethyst/commons/model/Note.kt | 6 ++ .../commons/model/NoteIsHiddenForTest.kt | 29 +++++++ .../quartz/nip51Lists/muteList/TagArrayExt.kt | 7 ++ .../nip51Lists/muteList/tags/HashtagTag.kt | 67 +++++++++++++++ .../nip51Lists/muteList/tags/MuteTag.kt | 4 +- .../muteList/tags/HashtagTagTest.kt | 81 +++++++++++++++++++ 14 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTagTest.kt 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 ab22669284..807b3aee80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -4719,6 +4719,14 @@ class Account( sendMyPublicAndPrivateOutbox(muteList.showWord(word)) } + suspend fun hideHashtag(hashtag: String) { + sendMyPublicAndPrivateOutbox(muteList.hideHashtag(hashtag)) + } + + suspend fun showHashtag(hashtag: String) { + muteList.showHashtag(hashtag)?.let { sendMyPublicAndPrivateOutbox(it) } + } + suspend fun hideUser(pubkeyHex: HexKey) { sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt index d23ccb0d78..a2946aec11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -57,6 +58,7 @@ class HiddenUsersState( ): LiveHiddenUsers { val hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null } val hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null } + val hiddenHashtags = blockList.mapNotNullTo(mutableSetOf()) { if (it is HashtagTag) it.hashtag.lowercase() else null } + muteList.mapNotNull { if (it is HashtagTag) it.hashtag.lowercase() else null } val mutedThreads = muteList.mapNotNullTo(mutableSetOf()) { if (it is EventTag) it.eventId else null } return LiveHiddenUsers( @@ -67,6 +69,7 @@ class HiddenUsersState( hiddenUsers = hiddenUsers, spammers = transientHiddenUsers, hiddenWords = hiddenWords, + hiddenHashtags = hiddenHashtags, maxHashtagLimit = maxHashtagLimit, mutedThreads = mutedThreads, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt index ae90a7c09b..622335d2e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -142,6 +143,39 @@ class MuteListState( } } + suspend fun hideHashtag(hashtag: String): MuteListEvent { + val muteList = getMuteList() + + return if (muteList != null) { + MuteListEvent.add( + earlierVersion = muteList, + mute = HashtagTag(hashtag), + isPrivate = true, + signer = signer, + ) + } else { + MuteListEvent.create( + mute = HashtagTag(hashtag), + isPrivate = true, + signer = signer, + ) + } + } + + suspend fun showHashtag(hashtag: String): MuteListEvent? { + val muteList = getMuteList() + + return if (muteList != null) { + MuteListEvent.remove( + earlierVersion = muteList, + mute = HashtagTag(hashtag), + signer = signer, + ) + } else { + null + } + } + suspend fun hideThread(rootHex: HexKey): MuteListEvent { val muteList = getMuteList() return if (muteList != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 82a1de7a66..9dbcb3cceb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -396,6 +396,33 @@ fun observeUserIsFollowingHashtag( return flow.collectAsStateWithLifecycle(hashtag in accountViewModel.account.hashtagList.flow.value) } +@SuppressLint("StateFlowValueCalledInComposition") +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserIsMutingHashtag( + hashtag: String, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(accountViewModel, hashtag) { + accountViewModel.account.hiddenUsers.flow + .mapLatest { it.isHashtagHidden(hashtag) } + .onStart { + emit( + accountViewModel.account.hiddenUsers.flow.value + .isHashtagHidden(hashtag), + ) + }.distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + + return flow.collectAsStateWithLifecycle( + accountViewModel.account.hiddenUsers.flow.value + .isHashtagHidden(hashtag), + ) +} + @SuppressLint("StateFlowValueCalledInComposition") @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable 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 2fc75b1839..75f0654081 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 @@ -1693,6 +1693,10 @@ class AccountViewModel( fun hideWord(word: String) = launchSigner { account.hideWord(word) } + fun hideHashtag(tag: String) = launchSigner { account.hideHashtag(tag) } + + fun showHashtag(tag: String) = launchSigner { account.showHashtag(tag) } + fun isLoggedUser(pubkeyHex: HexKey?): Boolean = account.signer.pubKey == pubkeyHex fun isLoggedUser(user: User?): Boolean = isLoggedUser(user?.pubkeyHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt index 10900d7bc6..4c66c153a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt @@ -25,15 +25,26 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingHashtag +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsMutingHashtag import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded @@ -46,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.dal.HashtagFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable @@ -172,4 +184,57 @@ fun HashtagActionOptions( } } } + + HashtagMuteMenu(tag, accountViewModel) +} + +@Composable +fun HashtagMuteMenu( + tag: String, + accountViewModel: AccountViewModel, +) { + var menuOpen by remember { mutableStateOf(false) } + val isMuted by observeUserIsMutingHashtag(tag, accountViewModel) + + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + modifier = Modifier.size(22.dp), + ) + } + + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (isMuted) { + DropdownMenuItem( + text = { Text(stringRes(R.string.unmute_hashtag)) }, + onClick = { + menuOpen = false + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_show_word, + ) + } else { + accountViewModel.showHashtag(tag) + } + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringRes(R.string.mute_hashtag)) }, + onClick = { + menuOpen = false + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_hide_word, + ) + } else { + accountViewModel.hideHashtag(tag) + } + }, + ) + } + } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6c8d8cb12b..46b4266f03 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2328,6 +2328,8 @@ Hidden Words Hide new word or sentence + Mute hashtag + Unmute hashtag Muted threads No muted threads Unknown thread · %1$s diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index e8583e0345..2cb3af0592 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -61,11 +61,15 @@ data class LiveHiddenUsers( val spammers: Set = emptySet(), val hiddenWords: Set = emptySet(), val mutedThreads: Set = emptySet(), + // Lowercased hashtags (without the leading '#') the user has muted via NIP-51 `"t"` entries. + val hiddenHashtags: Set = emptySet(), val maxHashtagLimit: Int = 8, ) { fun isUserHidden(userHex: String) = hiddenUsers.contains(userHex) || spammers.contains(userHex) fun isThreadMuted(rootHex: String) = mutedThreads.contains(rootHex) + + fun isHashtagHidden(hashtag: String) = hiddenHashtags.contains(hashtag.lowercase()) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 61cec122e3..802ba935b7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -1309,6 +1309,12 @@ open class Note( } } + if (accountChoices.hiddenHashtags.isNotEmpty()) { + if (thisEvent.anyHashTag { it.lowercase() in accountChoices.hiddenHashtags }) { + return true + } + } + if (accountChoices.hiddenWordsCase.isNotEmpty()) { if (thisEvent is BaseThreadedEvent && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) { return true diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt index b8c1378006..2f5cbee961 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt @@ -90,4 +90,33 @@ class NoteIsHiddenForTest { assertTrue(note.isHiddenFor(choices), "Note whose author is hidden must still be hidden") } + + private fun tTag(hashtag: String) = arrayOf("t", hashtag) + + @Test + fun note_withMutedHashtag_isHidden() { + val event = textNoteEvent(id = replyId, eTags = arrayOf(tTag("bitcoin"))) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(hiddenHashtags = setOf("bitcoin")) + + assertTrue(note.isHiddenFor(choices), "Note tagged with a muted hashtag must be hidden") + } + + @Test + fun note_withMutedHashtag_isCaseInsensitive() { + val event = textNoteEvent(id = replyId, eTags = arrayOf(tTag("Bitcoin"))) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(hiddenHashtags = setOf("bitcoin")) + + assertTrue(note.isHiddenFor(choices), "Muted-hashtag matching must ignore case") + } + + @Test + fun note_withoutMutedHashtag_isNotHidden() { + val event = textNoteEvent(id = replyId, eTags = arrayOf(tTag("nostr"))) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(hiddenHashtags = setOf("bitcoin")) + + assertFalse(note.isHiddenFor(choices), "Note without the muted hashtag must not be hidden") + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt index b94b093067..fe7f5a57ae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip51Lists.muteList import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -43,3 +44,9 @@ fun TagArray.mutedThreads() = mapNotNull(EventTag::parse) fun TagArray.mutedThreadIds() = mapNotNull(EventTag::parseId) fun TagArray.mutedThreadIdSet() = mapNotNullTo(mutableSetOf(), EventTag::parseId) + +fun TagArray.mutedHashtags() = mapNotNull(HashtagTag::parse) + +fun TagArray.mutedHashtagIds() = mapNotNull(HashtagTag::parseId) + +fun TagArray.mutedHashtagIdSet() = mapNotNullTo(mutableSetOf(), HashtagTag::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTag.kt new file mode 100644 index 0000000000..59e3f9fb30 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTag.kt @@ -0,0 +1,67 @@ +/* + * 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.nip51Lists.muteList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-51 mute-list hashtag entry (`"t"`). Hides notes that carry the given + * hashtag. Per NIP-24, `t` values are conventionally lowercased. + */ +@Immutable +class HashtagTag( + val hashtag: String, +) : MuteTag { + override fun toTagArray() = assemble(hashtag) + + override fun toTagIdOnly() = assemble(hashtag) + + companion object { + const val TAG_NAME = "t" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun isTagged( + tag: Array, + hashtag: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == hashtag + + fun parse(tag: Array): HashtagTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return HashtagTag(tag[1]) + } + + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(hashtag: String) = arrayOfNotNull(TAG_NAME, hashtag) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt index 7d95b71067..df4b77a595 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt @@ -28,8 +28,8 @@ sealed interface MuteTag { fun toTagIdOnly(): Tag companion object { - fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) || EventTag.isTagged(tag) + fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) || EventTag.isTagged(tag) || HashtagTag.isTagged(tag) - fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) ?: EventTag.parse(tag) + fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) ?: EventTag.parse(tag) ?: HashtagTag.parse(tag) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTagTest.kt new file mode 100644 index 0000000000..8a0ec89c39 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTagTest.kt @@ -0,0 +1,81 @@ +/* + * 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.nip51Lists.muteList.tags + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HashtagTagTest { + private val hashtag = "bitcoin" + + @Test fun isTagged_acceptsHashtag() { + assertTrue(HashtagTag.isTagged(arrayOf("t", hashtag))) + } + + @Test fun isTagged_rejectsEmpty() { + assertFalse(HashtagTag.isTagged(arrayOf("t", ""))) + } + + @Test fun isTagged_rejectsWrongPrefix() { + assertFalse(HashtagTag.isTagged(arrayOf("word", hashtag))) + } + + @Test fun parse_extractsHashtag() { + val tag = assertNotNull(HashtagTag.parse(arrayOf("t", hashtag))) + assertEquals(hashtag, tag.hashtag) + } + + @Test fun parse_rejectsEmpty() { + assertNull(HashtagTag.parse(arrayOf("t", ""))) + } + + @Test fun parse_rejectsWrongPrefix() { + assertNull(HashtagTag.parse(arrayOf("p", hashtag))) + } + + @Test fun parseId_extractsValue() { + assertEquals(hashtag, HashtagTag.parseId(arrayOf("t", hashtag))) + } + + @Test fun toTagArray_roundTrips() { + val parsed = assertNotNull(HashtagTag.parse(HashtagTag(hashtag).toTagArray())) + assertEquals(hashtag, parsed.hashtag) + } + + @Test fun muteTagCompanion_parsesHashtagTag() { + val parsed = assertNotNull(MuteTag.parse(arrayOf("t", hashtag))) + assertTrue(parsed is HashtagTag) + } + + @Test fun muteTagCompanion_isTaggedRecognizesHashtagTag() { + assertTrue(MuteTag.isTagged(arrayOf("t", hashtag))) + } + + @Test fun muteTagCompanion_doesNotConfuseHashtagWithWord() { + // "t" must parse as a hashtag, not a word; "word" must parse as a word. + assertTrue(MuteTag.parse(arrayOf("t", hashtag)) is HashtagTag) + assertTrue(MuteTag.parse(arrayOf("word", hashtag)) is WordTag) + } +} From bea342a73113ae498e2600b87fc11f264a6f8079 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:00:44 +0000 Subject: [PATCH 28/45] chore: remove dead kind:10011 GalleryListEvent GalleryListEvent (kind 10011, @Deprecated "Replaced by NIP-68") was unreachable: EventFactory routes kind 10011 to ExternalIdentitiesEvent, and no other code referenced the class. It also shadowed NIP-51's "Favorite follow sets" kind. Removing the dead class; the live profile gallery entry (ProfileGalleryEntryEvent) and its builders are untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8 --- .../profileGallery/GalleryListEvent.kt | 150 ------------------ 1 file changed, 150 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt deleted file mode 100644 index f30941c49c..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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.experimental.profileGallery - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag -import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent -import com.vitorpamplona.quartz.utils.TimeUtils - -@Deprecated("Replaced by NIP-68") -@Immutable -class GalleryListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Suppress("DEPRECATION") - companion object { - const val KIND = 10011 - const val GALLERY_TAG_NAME = "url" - - suspend fun addEvent( - earlierVersion: GalleryListEvent?, - eventId: HexKey, - url: String, - relay: String?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ) = addTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, relay, signer, createdAt) - - suspend fun addTag( - earlierVersion: GalleryListEvent?, - tagName: String, - eventId: HexKey, - url: String, - relay: String?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent { - val tags = arrayOf(tagName, url, eventId) - if (relay != null) { - tags + relay - } - - return add( - earlierVersion, - arrayOf(tags), - signer, - createdAt, - ) - } - - suspend fun add( - earlierVersion: GalleryListEvent?, - listNewTags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent = - create( - content = earlierVersion?.content ?: "", - tags = listNewTags.plus(earlierVersion?.tags ?: arrayOf()), - signer = signer, - createdAt = createdAt, - ) - - suspend fun removeEvent( - earlierVersion: GalleryListEvent, - eventId: HexKey, - url: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ) = removeTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, signer, createdAt) - - suspend fun removeReplaceable( - earlierVersion: GalleryListEvent, - aTag: ATag, - url: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ) = removeTag(earlierVersion, GALLERY_TAG_NAME, aTag.toTag(), url, signer, createdAt) - - private suspend fun removeTag( - earlierVersion: GalleryListEvent, - tagName: String, - eventId: HexKey, - url: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent = - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == url && it[2] == eventId) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - ) - - suspend fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent = signer.sign(createdAt, KIND, tags, content) - } - - @Immutable - data class GalleryUrl( - val url: String, - val id: String, - val relay: String?, - ) { - fun encode(): String = ":$url:$id:$relay" - - companion object { - fun decode(encodedGallerySetup: String): GalleryUrl? { - val galleryParts = encodedGallerySetup.split(":", limit = 3) - return if (galleryParts.size > 3) { - GalleryUrl(galleryParts[1], galleryParts[2], galleryParts[3]) - } else { - null - } - } - } - } -} From c236474f88f8edda7cf02cde05430b56b3dc1b78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:29:10 +0000 Subject: [PATCH 29/45] feat(nip29): timeline refs, custom roles, subgroup nav, naddr invites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows up the subgroup protocol work with four NIP-29 compliance/UX gaps. previous timeline references (spec §Timeline references): - RelayGroupChannel.previousEventRefs(): first-8-char id prefixes of the most recent events seen from the host relay, excluding the sender's own, capped at the spec's 50-event window. Only draws from events actually received in the channel so the host relay is known to have them. - Populate the `previous` tag on all outgoing group events: kind-9 chat and replies, kind-1111 minichat comments, kind-11 threads, and group replies. Custom roles (kind 39003): - Route SupportedRolesEvent onto the channel (LocalCache.consume + a RelayGroupChannel.supportedRoles field) instead of only storing it. - Members screen: when the relay advertises a role set, offer those roles when assigning (admins), and show each member's real relay-assigned role label instead of collapsing everything to admin/moderator. Falls back to the built-in admin/moderator shortcuts when no 39003 is published. Subgroup navigation (spec §Subgroups): - RelayGroupSubgroupsBar: a self-hiding bar under the pinned bar showing a breadcrumb up to the parent group and chips for the child subgroups (in the relay's `child` order), each opening that group on the same host relay. naddr invite codes (spec §Group identifier): - GroupNAddrInvite parses the `naddr1…?invite=` suffix; both the tap handler (ClickableRoute) and the deep-link handler (MainActivity) now feed it into the kind-9021 join request so a shared invite naddr auto-joins. Tests for the naddr invite parser. spotless clean; quartz tests green; amethyst compiles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S --- .../vitorpamplona/amethyst/model/Account.kt | 8 +- .../amethyst/model/LocalCache.kt | 30 +++- .../vitorpamplona/amethyst/ui/MainActivity.kt | 16 +- .../amethyst/ui/components/ClickableRoute.kt | 6 +- .../relayGroup/RelayGroupChannelView.kt | 6 + .../relayGroup/RelayGroupMembersScreen.kt | 94 +++++++--- .../relayGroup/RelayGroupSubgroupsBar.kt | 169 ++++++++++++++++++ .../send/ChannelNewMessageViewModel.kt | 8 +- amethyst/src/main/res/values/strings.xml | 1 + .../nip29RelayGroups/RelayGroupChannel.kt | 44 +++++ .../nip29RelayGroups/GroupNAddrInvite.kt | 55 ++++++ .../nip29RelayGroups/GroupNAddrInviteTest.kt | 62 +++++++ 12 files changed, 461 insertions(+), 38 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt 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 88c531f1a1..12443a83a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -256,6 +256,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent @@ -2174,6 +2175,7 @@ class Account( signer.sign( CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) { hTag(group.groupId.id) + previous(group.previousEventRefs(pubKey)) }, ) cache.justConsumeMyOwnEvent(signed) @@ -2745,7 +2747,11 @@ class Account( title: String, body: String, ) { - val template = ThreadEvent.build(body, title) { hTag(channel.groupId.id) } + val template = + ThreadEvent.build(body, title) { + hTag(channel.groupId.id) + previous(channel.previousEventRefs(pubKey)) + } signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 90a6adbe3a..bbbbc2d2d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1919,6 +1919,20 @@ object LocalCache : ILocalCache, ICacheProvider { return new } + /** NIP-29 relay-declared supported roles (kind 39003) → the group's role set. */ + fun consume( + event: SupportedRolesEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val new = consumeBaseReplaceable(event, relay, wasVerified) + if (relay != null) { + val latest = getOrCreateAddressableNote(event.address()).event as? SupportedRolesEvent + latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateSupportedRoles(it) } + } + return new + } + /** * Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, … * carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic @@ -3876,16 +3890,18 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } - // Remaining NIP-29 relay-group kinds. The two relay-signed addressables (39003 - // roles, 39004 AV participants) are durable group state alongside 39000/39001/39002, - // so they're stored replaceably. The 9xxx moderation actions and join/leave requests - // are regular one-shot events the relay is authoritative for (it applies them and - // republishes the 39000/39001/39002); we store them so they're queryable and don't - // fall through to the "Not Supported" warning, but we don't act on them client-side. + // 39003 (relay-declared roles) is durable group state like 39000/39001/39002: + // route it onto the channel so a moderation UI can offer the relay's role set. is SupportedRolesEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) + consume(event, relay, wasVerified) } + // Remaining NIP-29 relay-group kinds. The relay-signed 39004 AV-participants + // addressable is durable group state, so it's stored replaceably. The 9xxx + // moderation actions and join/leave requests are regular one-shot events the + // relay is authoritative for (it applies them and republishes the + // 39000/39001/39002); we store them so they're queryable and don't fall through + // to the "Not Supported" warning, but we don't act on them client-side. is GroupParticipantsEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index d87ea9db23..30fa7e80c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink +import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent @@ -226,7 +227,8 @@ fun uriToRoute( relayGroupInviteRoute(uri)?.let { return it } concordInviteRoute(uri)?.let { return it } - val nip19 = Nip19Parser.uriToRoute(uri)?.entity + val parsedNip19 = Nip19Parser.uriToRoute(uri) + val nip19 = parsedNip19?.entity if (nip19 != null) { LocalCache.consume(nip19) @@ -252,7 +254,7 @@ fun uriToRoute( } is NAddress -> { - relayGroupDirectRoute(nip19) + relayGroupDirectRoute(nip19, parsedNip19.additionalChars) ?: routeFor( note = LocalCache.getOrCreateAddressableNote(nip19.address()), loggedIn = account, @@ -341,10 +343,16 @@ private fun calendarDirectRoute(nip19: NAddress): Route? = * id alone can't be resolved. With a hint we open the group chat straight away * (cold start included); without one there's nowhere to look, so fall through. */ -private fun relayGroupDirectRoute(nip19: NAddress): Route? { +private fun relayGroupDirectRoute( + nip19: NAddress, + additionalChars: String?, +): Route? { if (nip19.kind != GroupMetadataEvent.KIND) return null val relay = nip19.relay.firstOrNull() ?: return null - return Route.RelayGroup(nip19.dTag, relay.url) + // The spec allows an invite code appended as `naddr1…?invite=`; it arrives + // in the trailing chars after the bech32 body. Carry it so the group auto-joins. + val inviteCode = GroupNAddrInvite.parse(additionalChars) + return Route.RelayGroup(nip19.dTag, relay.url, inviteCode = inviteCode) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 32780c6599..a0e80679bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNIP19 +import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import kotlinx.collections.immutable.ImmutableList @@ -212,10 +213,13 @@ private fun DisplayAddress( // only per relay), so we need that hint to open it. val groupRelay = if (nip19.kind == GroupMetadataEvent.KIND) nip19.relay.firstOrNull() else null if (groupRelay != null) { + // An invite code may be appended to the group naddr as `?invite=`; it lands + // in additionalChars. Pass it through so the group auto-joins with a kind-9021 code. + val inviteCode = GroupNAddrInvite.parse(additionalChars) CreateClickableText( clickablePart = "#${nip19.dTag}", suffix = additionalChars, - route = Route.RelayGroup(nip19.dTag, groupRelay.url), + route = Route.RelayGroup(nip19.dTag, groupRelay.url, inviteCode = inviteCode), nav = nav, ) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index befde7fc84..fb93f065a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -149,6 +149,12 @@ private fun ChannelView( onJumpToNote = { jumpToNoteId.value = it.idHex }, ) + RelayGroupSubgroupsBar( + channel = liveChannel, + accountViewModel = accountViewModel, + nav = nav, + ) + Column( modifier = remember { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index 70b810d053..23867f5dd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -95,6 +95,10 @@ fun RelayGroupMembersScreen( private class RosterEntry( val pubkey: HexKey, val membership: RelayGroupMembership, + // The relay-assigned role names for this member (from the kind-39001 admin list), + // e.g. ["admin"], ["ceo", "gardener"]. Empty for plain members. Used to show the + // real relay-defined role label instead of only the coarse admin/moderator bucket. + val roles: List, ) @Composable @@ -121,9 +125,10 @@ private fun RelayGroupMembers( // is overkill here — rely on relay order but push elevated roles to the top. val roster = remember(channel.admins, channel.members) { + val rolesByPubkey = channel.admins.associate { it.pubKey to it.roles } val everyone = (channel.admins.map { it.pubKey } + channel.members).distinct() everyone - .map { RosterEntry(it, channel.membershipOf(it)) } + .map { RosterEntry(it, channel.membershipOf(it), rolesByPubkey[it] ?: emptyList()) } .sortedBy { it.membership.rank() } } @@ -226,7 +231,7 @@ private fun RelayGroupMemberRow( } } - MemberRoleBadge(entry.membership) + MemberRoleBadge(entry) // Moderators can act on others (not themselves); the relay is the final // authority, but hide obviously-useless menus (a moderator can't touch an admin). @@ -244,23 +249,56 @@ private fun RelayGroupMemberRow( ) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) { - DropdownMenuItem( - text = { Text(stringRes(R.string.relay_group_make_admin)) }, - onClick = { - menuOpen = false - accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN)) - }, - ) - } - if (entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) { - DropdownMenuItem( - text = { Text(stringRes(R.string.relay_group_make_moderator)) }, - onClick = { - menuOpen = false - accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR)) - }, - ) + val declaredRoles = channel.supportedRoles + if (declaredRoles.isNotEmpty()) { + // The relay declares its own role set (kind 39003) — offer exactly those + // instead of the built-in admin/moderator pair. Roles are privilege grants, + // so only admins assign them; the relay is the final authority. + if (viewerIsAdmin) { + declaredRoles.forEach { role -> + val alreadyHasRole = entry.roles.any { it.equals(role.name, true) } + if (!alreadyHasRole) { + DropdownMenuItem( + text = { + Column { + Text(stringRes(R.string.relay_group_assign_role, role.name)) + role.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(role.name)) + }, + ) + } + } + } + } else { + // No 39003 role set advertised: fall back to the built-in admin/moderator shortcuts. + if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_make_admin)) }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN)) + }, + ) + } + if (entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_make_moderator)) }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR)) + }, + ) + } } if (entry.membership == RelayGroupMembership.MODERATOR || entry.membership == RelayGroupMembership.ADMIN) { DropdownMenuItem( @@ -310,13 +348,21 @@ private fun RelayGroupMemberRow( } } -/** A small colored pill for an elevated role; plain members get nothing. */ +/** + * A small colored pill for an elevated role; plain members get nothing. + * + * When the relay assigns explicit role labels (kind 39001), those are shown verbatim + * (e.g. `ceo`, `moderator`) so relay-defined roles are visible rather than collapsed + * into the coarse admin/moderator bucket. Falls back to the generic admin/moderator + * label when the member is in the admin list without any named role. + */ @Composable -private fun MemberRoleBadge(membership: RelayGroupMembership) { +private fun MemberRoleBadge(entry: RosterEntry) { val label = - when (membership) { - RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin) - RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator) + when { + entry.roles.isNotEmpty() -> entry.roles.joinToString(", ") + entry.membership == RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin) + entry.membership == RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator) else -> return } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt new file mode 100644 index 0000000000..2e3f13ade3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt @@ -0,0 +1,169 @@ +/* + * 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.chats.publicChannels.relayGroup + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** Cap on how many child chips get their own warm-up subscription, to bound relay load. */ +private const val MAX_SUBGROUP_CHIPS = 20 + +/** + * Self-hiding NIP-29 subgroups bar shown under the group's top bar (below the pinned + * bar). Renders nothing for a flat group; when the group is part of a hierarchy it shows + * a breadcrumb chip up to the parent group and a horizontally-scrollable row of chips for + * the direct child subgroups, in the relay's advertised `child` order. Tapping a chip + * opens that group. Parent and children always live on the same host relay. + */ +@Composable +fun RelayGroupSubgroupsBar( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val parentId = channel.parentGroupId() + val childIds = channel.childGroupIds() + if (parentId == null && childIds.isEmpty()) return + + val relay = channel.groupId.relayUrl + + Surface( + color = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (parentId != null) { + SubgroupChip( + groupId = GroupId(parentId, relay), + leadingSymbol = MaterialSymbols.ArrowUpward, + accountViewModel = accountViewModel, + nav = nav, + ) + } + childIds.take(MAX_SUBGROUP_CHIPS).forEach { childId -> + SubgroupChip( + groupId = GroupId(childId, relay), + leadingSymbol = MaterialSymbols.Group, + accountViewModel = accountViewModel, + nav = nav, + ) + } + if (childIds.size > MAX_SUBGROUP_CHIPS) { + // Never claim to show all children when we don't: surface the remainder count. + Text( + text = "+${childIds.size - MAX_SUBGROUP_CHIPS}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider(thickness = DividerThickness, color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + } + } +} + +/** + * A single tappable group chip. Resolves the group by id on the shared host relay and warms + * its metadata so the name fills in, then navigates to that group when tapped. + */ +@Composable +private fun SubgroupChip( + groupId: GroupId, + leadingSymbol: MaterialSymbol, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadRelayGroupChannel(groupId, accountViewModel) { child -> + // Fetch the child's 39000 (name/picture) while this bar is visible. + RelayGroupWarmupSubscription(child, accountViewModel.dataSources().relayGroupWarmup, accountViewModel) + + val childState by child + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val liveChild = childState.channel as? RelayGroupChannel ?: child + + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable { nav.nav(Route.RelayGroup(groupId.id, groupId.relayUrl.url)) }, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + symbol = leadingSymbol, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(16.dp), + ) + Text( + text = liveChild.toBestDisplayName(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 995a5367d6..78e7ad613e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -90,6 +90,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -459,7 +460,10 @@ open class ChannelNewMessageViewModel : val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event if (minichatParent != null) { return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) { - if (channel is RelayGroupChannel) hTag(channel.groupId.id) + if (channel is RelayGroupChannel) { + hTag(channel.groupId.id) + previous(channel.previousEventRefs(account.userProfile().pubkeyHex)) + } hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) @@ -594,6 +598,7 @@ open class ChannelNewMessageViewModel : // composer but is missing from the signed event. ChatEvent.reply(tagger.message, replyingToEvent) { hTag(channel.groupId.id) + previous(channel.previousEventRefs(account.userProfile().pubkeyHex)) pTag(replyingToEvent.toPTag()) hashtags(findHashtags(tagger.message)) @@ -608,6 +613,7 @@ open class ChannelNewMessageViewModel : } else { ChatEvent.build(tagger.message) { hTag(channel.groupId.id) + previous(channel.previousEventRefs(account.userProfile().pubkeyHex)) hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6c8d8cb12b..fb2a870b0c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2154,6 +2154,7 @@ Members Make admin Make moderator + Assign role: %1$s Remove role Remove from group Remove %1$s from this group? They will lose access until re-added or re-invited. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 8c6efff29f..b1e510c991 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -32,7 +32,9 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.RoleTag import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -79,6 +81,16 @@ class RelayGroupChannel( private set private var pinnedUpdatedAt: Long = 0 + /** + * Relay-declared roles this group supports (kind 39003), e.g. `admin`, `moderator`, + * `ceo`. Empty until (or unless) the relay publishes a 39003 for the group. These are + * the role names a moderation UI should offer when assigning a role, since the exact + * set is relay-defined (NIP-29 §Group management). + */ + var supportedRoles: List = emptyList() + private set + private var supportedRolesUpdatedAt: Long = 0 + /** * Members ∪ admins, recomputed only when a roster event lands. [memberCount] and the discovery * feed read this per note / per recomposition, so caching it avoids rebuilding the set each read. @@ -185,8 +197,40 @@ class RelayGroupChannel( updateChannelInfo() } + fun updateSupportedRoles(event: SupportedRolesEvent) { + // Only newer definitions supersede; equal-or-older is dropped (no redundant emit). + if (event.createdAt <= supportedRolesUpdatedAt) return + supportedRoles = event.roles() + supportedRolesUpdatedAt = event.createdAt + updateChannelInfo() + } + fun isPinned(eventId: HexKey): Boolean = eventId in pinnedEventIds + /** + * NIP-29 timeline references (`previous` tag) for an event about to be sent to this + * group. Returns the first-8-hex-char id prefixes of the most recent events seen here + * from the host relay, excluding [selfPubkey]'s own posts. + * + * The spec uses these to stop a group's events from being replayed out of context on a + * forked relay: a relay rejects an event whose `previous` refs it doesn't recognise, so + * we only draw from events we actually received in this channel (guaranteeing the host + * relay has them) and cap at the spec's window of the last 50. It recommends including + * at least 3; [max] bounds how many we attach. + */ + fun previousEventRefs( + selfPubkey: HexKey, + max: Int = 8, + ): List = + notes + .mapNotNull { _, note -> + val createdAt = note.createdAt() + if (createdAt != null && note.author?.pubkeyHex != selfPubkey) note to createdAt else null + }.sortedByDescending { it.second } + .take(50) + .take(max) + .map { it.first.idHex.take(8) } + /** * The shareable NIP-19 `naddr` coordinate for this group's metadata (kind * 39000, authored by the relay's own key, with the host relay as a hint), or diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt new file mode 100644 index 0000000000..093568b275 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt @@ -0,0 +1,55 @@ +/* + * 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.nip29RelayGroups + +/** + * Reads the optional invite code appended to a NIP-29 group identifier. + * + * The spec shares a group as its `kind:39000` `naddr1…` and lets an invite code be + * appended as a query suffix: + * ``` + * naddr1…?invite= + * ``` + * Because the bech32 charset has no `?`, everything before the `?` is still a valid + * naddr on its own — so a NIP-19 parser hands back the `?invite=` remainder as + * the "additional characters" after the entity. This extracts the `invite` value from + * that remainder. Clients then send it in the `code` tag of the `kind:9021` join + * request. A client that doesn't understand the suffix can simply ignore it. + */ +object GroupNAddrInvite { + private const val PARAM = "invite" + + /** + * Extracts the invite code from the [suffix] that trails a group `naddr` (e.g. + * `?invite=abc123` or `?foo=bar&invite=abc123`), or null when there is none. + */ + fun parse(suffix: String?): String? { + if (suffix.isNullOrEmpty()) return null + val query = suffix.substringAfter('?', "") + if (query.isEmpty()) return null + + return query + .split('&') + .firstOrNull { it.startsWith("$PARAM=") } + ?.removePrefix("$PARAM=") + ?.takeIf { it.isNotEmpty() } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt new file mode 100644 index 0000000000..fb9173a351 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt @@ -0,0 +1,62 @@ +/* + * 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.nip29RelayGroups + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * NIP-29 group identifier: the optional `?invite=` suffix appended to a + * `kind:39000` `naddr` (extracted from the trailing chars a NIP-19 parser returns + * after the bech32 body). + */ +class GroupNAddrInviteTest { + @Test + fun parsesInviteParam() { + assertEquals("abc123", GroupNAddrInvite.parse("?invite=abc123")) + } + + @Test + fun parsesInviteAmongOtherParams() { + assertEquals("abc123", GroupNAddrInvite.parse("?foo=bar&invite=abc123")) + assertEquals("abc123", GroupNAddrInvite.parse("?invite=abc123&foo=bar")) + } + + @Test + fun nullWhenNoSuffix() { + assertNull(GroupNAddrInvite.parse(null)) + assertNull(GroupNAddrInvite.parse("")) + assertNull(GroupNAddrInvite.parse("?")) + assertNull(GroupNAddrInvite.parse("?other=1")) + } + + @Test + fun nullWhenInviteEmpty() { + assertNull(GroupNAddrInvite.parse("?invite=")) + } + + @Test + fun ignoresLeadingBech32RemainderWithoutQuery() { + // A plain trailing word (not a query) carries no invite. + assertNull(GroupNAddrInvite.parse("someword")) + } +} From c280c14b3cafbd1630a0cc4a5f698671f95bb793 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:45:58 +0000 Subject: [PATCH 30/45] =?UTF-8?q?feat(nip29):=20subgroup=20authoring=20?= =?UTF-8?q?=E2=80=94=20parent-group=20picker=20in=20the=20edit=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets an admin nest a group under a parent (or promote it back to top-level) from the create/edit metadata form, completing the subgroup feature's authoring side (viewing/navigation already shipped). - RelayGroupMetadataViewModel: editable parentGroupId + preserved children, seeded from the group's metadata and passed through on save. NIP-29 requires a kind-9002 to re-list every child, so children are carried untouched. - Account.createRelayGroup: optional parent so a group can be created nested. - RelayGroupParentPicker: a hero selector card (gradient badge, parent avatar or a top-level/home glyph) that opens a modal bottom-sheet picker — search field, a "no parent (top-level)" choice, and the relay's genuine groups with avatars, member counts and an animated selection mark. Excludes the group itself and its own descendants so a cycle can't be formed, and streams the relay directory while open so candidates fill in live. spotless clean; amethyst compiles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S --- .../vitorpamplona/amethyst/model/Account.kt | 2 + .../relayGroup/RelayGroupMetadataScreen.kt | 4 + .../relayGroup/RelayGroupMetadataViewModel.kt | 25 + .../relayGroup/RelayGroupParentPicker.kt | 489 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 9 + 5 files changed, 529 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt 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 12443a83a0..0961363ae7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2703,6 +2703,7 @@ class Account( isRestricted: Boolean = false, hashtags: List = emptyList(), geohashes: List = emptyList(), + parent: String? = null, ): GroupId { signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) } @@ -2715,6 +2716,7 @@ class Account( status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), hashtags = hashtags, geohashes = geohashes, + parent = parent, ) signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index 66dc72dd20..5f2bbe0c45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -226,6 +226,10 @@ private fun RelayGroupMetadataScaffold( Spacer(Modifier.height(16.dp)) GroupMetadataFields(viewModel) + + Spacer(Modifier.height(16.dp)) + + ParentGroupSection(viewModel, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index 5dc7735f39..c661c8f289 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -86,6 +86,20 @@ class RelayGroupMetadataViewModel : ViewModel() { var isHidden by mutableStateOf(false) var isRestricted by mutableStateOf(false) + /** + * Subgroups: the parent group this group nests under, or null for a top-level group. + * Editable via the parent picker. Seeded from the current metadata in [prefillFrom]. + */ + var parentGroupId by mutableStateOf(null) + private set + + /** + * Subgroups: the group's current children, carried through untouched on save. NIP-29 + * requires a kind-9002 to re-list every child or the relay drops them, so we preserve + * whatever the relay last advertised rather than letting a metadata edit clear the tree. + */ + private var childrenGroupIds: List = emptyList() + var pickedMedia by mutableStateOf(null) private set @@ -135,6 +149,14 @@ class RelayGroupMetadataViewModel : ViewModel() { topics.value = TextFieldValue(event?.hashtags()?.distinct()?.joinToString(" ") ?: "") // Stored geohashes are mip-mapped into every prefix; the last (longest) is the real one. geohash.value = TextFieldValue(event?.geohashes()?.maxByOrNull { it.length } ?: "") + parentGroupId = channel.parentGroupId() + childrenGroupIds = channel.childGroupIds() + } + + /** Set (or clear, with null) the group's parent from the picker; marks the form touched. */ + fun setParent(groupId: String?) { + parentGroupId = groupId + markTouched() } /** Split the topics field into distinct, non-blank, lowercased hashtags (leading `#` dropped). */ @@ -222,6 +244,7 @@ class RelayGroupMetadataViewModel : ViewModel() { isRestricted = isRestricted, hashtags = hashtags, geohashes = geohashes, + parent = parentGroupId, ) } else { account.editRelayGroupMetadata( @@ -235,6 +258,8 @@ class RelayGroupMetadataViewModel : ViewModel() { isRestricted = isRestricted, hashtags = hashtags, geohashes = geohashes, + parent = parentGroupId, + children = childrenGroupIds, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt new file mode 100644 index 0000000000..5bb4c35dd6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt @@ -0,0 +1,489 @@ +/* + * 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.chats.publicChannels.relayGroup + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +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.Brush +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent + +/** + * Subgroup authoring: the "Parent group" section of the create/edit form. Shows the + * currently-chosen parent (or top-level) as a tappable hero card and, on tap, opens a + * bottom-sheet picker of the other groups on the same host relay. Selecting one nests + * this group under it via the kind-9002 `parent` tag when the form is saved. + */ +@Composable +fun ParentGroupSection( + viewModel: RelayGroupMetadataViewModel, + accountViewModel: AccountViewModel, +) { + val relay = viewModel.relay ?: return + var pickerOpen by remember { mutableStateOf(false) } + + Text( + text = stringRes(R.string.relay_group_section_structure), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.relay_group_parent_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + + Spacer(Modifier.height(10.dp)) + + ParentSelectorCard( + parentId = viewModel.parentGroupId, + relay = relay, + accountViewModel = accountViewModel, + onClick = { pickerOpen = true }, + ) + + if (pickerOpen) { + ParentGroupPickerSheet( + selfGroupId = viewModel.groupId, + selectedParentId = viewModel.parentGroupId, + relay = relay, + accountViewModel = accountViewModel, + onSelect = { + viewModel.setParent(it) + pickerOpen = false + }, + onDismiss = { pickerOpen = false }, + ) + } +} + +/** The hero card in the form showing the current parent (or top-level) with a gradient badge. */ +@Composable +private fun ParentSelectorCard( + parentId: String?, + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + val parentChannel = + remember(parentId, relay) { + parentId?.let { accountViewModel.getRelayGroupChannelIfExists(GroupId(it, relay)) } + } + // Warm the parent's metadata so its name/picture fills the card while the form is open. + parentChannel?.let { + RelayGroupWarmupSubscription(it, accountViewModel.dataSources().relayGroupWarmup, accountViewModel) + } + + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + GradientBadge { + if (parentId != null && parentChannel != null) { + RobohashFallbackAsyncImage( + robot = parentChannel.groupId.id, + model = parentChannel.profilePicture(), + contentDescription = parentChannel.toBestDisplayName(), + modifier = Modifier.size(46.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = false, + ) + } else { + Icon( + symbol = if (parentId == null) MaterialSymbols.Home else MaterialSymbols.Group, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(24.dp), + ) + } + } + + Column(Modifier.weight(1f)) { + Text( + text = stringRes(R.string.relay_group_parent_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = + when { + parentId == null -> stringRes(R.string.relay_group_parent_none) + parentChannel != null -> parentChannel.toBestDisplayName() + else -> parentId + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } +} + +/** A circular badge with a primary→tertiary gradient fill, hosting an icon or avatar. */ +@Composable +private fun GradientBadge(content: @Composable () -> Unit) { + Box( + modifier = + Modifier + .size(46.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + ), + ), + ), + contentAlignment = Alignment.Center, + content = { content() }, + ) +} + +/** Bottom-sheet picker of the other groups on this relay, plus a "top-level" choice. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ParentGroupPickerSheet( + selfGroupId: String, + selectedParentId: String?, + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + onSelect: (String?) -> Unit, + onDismiss: () -> Unit, +) { + // Stream the relay's directory while the picker is open so candidates fill in live. + RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel) + val relayInfo by loadRelayInfo(relay) + + var query by remember { mutableStateOf("") } + + // A group can't parent itself or any of its own descendants (would make a cycle the relay + // rejects), so exclude them from the candidate set. + val forbidden = + remember(selfGroupId, relay) { + descendantIdsOf(accountViewModel, selfGroupId, relay) + selfGroupId + } + + // Re-read the relay's genuine, relay-signed groups whenever a kind-39000 lands. + val candidates by produceState( + initialValue = pickCandidates(accountViewModel, relay, relayInfo, forbidden), + relay, + relayInfo, + forbidden, + ) { + value = pickCandidates(accountViewModel, relay, relayInfo, forbidden) + LocalCache + .observeEvents(Filter(kinds = listOf(GroupMetadataEvent.KIND))) + .collect { value = pickCandidates(accountViewModel, relay, relayInfo, forbidden) } + } + + val filtered = + remember(candidates, query) { + if (query.isBlank()) candidates else candidates.filter { it.anyNameStartsWith(query.trim()) } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 24.dp)) { + Text( + text = stringRes(R.string.relay_group_parent_pick_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + leadingIcon = { + Icon( + symbol = MaterialSymbols.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + placeholder = { Text(stringRes(R.string.relay_group_parent_search)) }, + shape = RoundedCornerShape(14.dp), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(12.dp)) + + LazyColumn( + modifier = Modifier.heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + item { + TopLevelRow(selected = selectedParentId == null) { onSelect(null) } + } + items(filtered, key = { it.groupId.id }) { channel -> + GroupPickRow( + channel = channel, + selected = channel.groupId.id == selectedParentId, + accountViewModel = accountViewModel, + onClick = { onSelect(channel.groupId.id) }, + ) + } + if (filtered.isEmpty()) { + item { + Text( + text = stringRes(R.string.relay_group_parent_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + ) + } + } + } + } + } +} + +/** The "make this a top-level group" choice at the head of the picker. */ +@Composable +private fun TopLevelRow( + selected: Boolean, + onClick: () -> Unit, +) { + PickRowScaffold(selected = selected, onClick = onClick) { + GradientBadge { + Icon( + symbol = MaterialSymbols.Home, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(22.dp), + ) + } + Column(Modifier.weight(1f)) { + Text( + text = stringRes(R.string.relay_group_parent_top_level_option), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringRes(R.string.relay_group_parent_none_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + SelectionMark(selected) + } +} + +/** One candidate parent group row: avatar, name, member count and a selection mark. */ +@Composable +private fun GroupPickRow( + channel: RelayGroupChannel, + selected: Boolean, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + val memberCount = channel.memberCount() + PickRowScaffold(selected = selected, onClick = onClick) { + RobohashFallbackAsyncImage( + robot = channel.groupId.id, + model = channel.profilePicture(), + contentDescription = channel.toBestDisplayName(), + modifier = Modifier.size(46.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = false, + ) + Column(Modifier.weight(1f)) { + Text( + text = channel.toBestDisplayName(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = + channel.summary()?.takeIf { it.isNotBlank() } + ?: if (memberCount > 0) { + pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount) + } else { + null + } + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + SelectionMark(selected) + } +} + +/** Shared row chrome: a tonal, rounded, clickable surface that highlights when selected. */ +@Composable +private fun PickRowScaffold( + selected: Boolean, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit, +) { + val bg by animateColorAsState( + if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + label = "parentRowBg", + ) + Surface( + shape = RoundedCornerShape(16.dp), + color = bg, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + content = content, + ) + } +} + +/** A filled check when selected, an empty ring otherwise. */ +@Composable +private fun SelectionMark(selected: Boolean) { + Icon( + symbol = if (selected) MaterialSymbols.CheckCircle else MaterialSymbols.RadioButtonUnchecked, + contentDescription = null, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.size(24.dp), + ) +} + +/** Genuine, relay-signed groups on [relay], minus the forbidden (self + descendant) ids, name-sorted. */ +private fun pickCandidates( + accountViewModel: AccountViewModel, + relay: NormalizedRelayUrl, + relayInfo: Nip11RelayInformation, + forbidden: Set, +): List = + accountViewModel + .getRelayGroupChannelsOnRelay(relay) + .asSequence() + .filter { it.groupId.id !in forbidden } + .filter { it.event != null && isRelaySignedRelayGroup(it, relayInfo) } + .sortedBy { it.toBestDisplayName().lowercase() } + .toList() + +/** + * The set of group ids reachable as descendants of [rootId] on [relay], following each group's + * advertised `child` links. Visited-guarded so a malformed cycle can't loop forever. + */ +private fun descendantIdsOf( + accountViewModel: AccountViewModel, + rootId: String, + relay: NormalizedRelayUrl, +): Set { + val result = mutableSetOf() + val queue = ArrayDeque() + accountViewModel.getRelayGroupChannelIfExists(GroupId(rootId, relay))?.childGroupIds()?.let { queue.addAll(it) } + while (queue.isNotEmpty()) { + val id = queue.removeFirst() + if (!result.add(id)) continue + accountViewModel.getRelayGroupChannelIfExists(GroupId(id, relay))?.childGroupIds()?.let { queue.addAll(it) } + } + return result +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fb2a870b0c..a1b086558b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2190,6 +2190,15 @@ bitcoin, nostr, art Location (geohash) u0nd + Structure + Nest this group under a parent to build a hierarchy. + Parent group + Top-level group + Choose a parent group + Search groups + No parent (top-level) + This group sits at the top level + No other groups on this relay yet Permissions Private Only members can read messages. From 0e8239b22f7afa2bf71362aae5c674f22bbaa571 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 04:19:25 +0000 Subject: [PATCH 31/45] =?UTF-8?q?fix(nip29):=20audit=20fixes=20=E2=80=94?= =?UTF-8?q?=20subgroup=20edit=20safety,=20subscription=20load,=20roles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs: - Metadata edit could re-root a subgroup or drop its children on a load race: the edit ViewModel snapshotted parent/children at prefill and overrode the Account-level live-read defaults. Children are no longer snapshotted (Account reads the live child list at save time), and the parent is only overridden when the user actually re-parents (parentTouched) — a plain rename can't re-root or orphan children anymore, even if metadata hadn't loaded yet. - Parent selector card cached a null channel via remember() and never refreshed, so the parent's name/picture never loaded and the warm-up never mounted. Now get-or-create + warm + observe the metadata flow. - previousEventRefs could let a note with an unresolved author slip past the self-exclusion and reference the sender's own event. Now requires a resolved author. - Assigning a relay-defined role replaced the member's whole role set while the menu implied additive; now keeps existing roles (entry.roles + role.name). - GroupNAddrInvite now also accepts a bare `invite=` remainder if the `?` is stripped upstream (+ test). Performance: - Subgroups bar mounted a full warm-up (metadata + content) subscription per child chip — up to ~21 relay subscriptions per open group. Replaced with one relay-directory subscription; chips read from cache. - Parent picker recomputed the whole candidate scan on every recomposition (each search keystroke) via a produceState initial-value argument; the scan now lives only in the producer with a cheap empty initial. spotless clean; quartz tests green; amethyst compiles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S --- .../relayGroup/RelayGroupMembersScreen.kt | 4 +- .../relayGroup/RelayGroupMetadataViewModel.kt | 17 ++++---- .../relayGroup/RelayGroupParentPicker.kt | 42 ++++++++++--------- .../relayGroup/RelayGroupSubgroupsBar.kt | 14 ++++--- .../nip29RelayGroups/RelayGroupChannel.kt | 6 ++- .../nip29RelayGroups/GroupNAddrInvite.kt | 4 +- .../nip29RelayGroups/GroupNAddrInviteTest.kt | 6 +++ 7 files changed, 56 insertions(+), 37 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index 23867f5dd4..37a24dea10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -273,7 +273,9 @@ private fun RelayGroupMemberRow( }, onClick = { menuOpen = false - accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(role.name)) + // Additive: NIP-29 allows multiple roles per member and the menu only + // offers roles they lack, so keep the ones they already hold. + accountViewModel.putRelayGroupUser(channel, entry.pubkey, entry.roles + role.name) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index c661c8f289..db8e535d49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -94,11 +94,11 @@ class RelayGroupMetadataViewModel : ViewModel() { private set /** - * Subgroups: the group's current children, carried through untouched on save. NIP-29 - * requires a kind-9002 to re-list every child or the relay drops them, so we preserve - * whatever the relay last advertised rather than letting a metadata edit clear the tree. + * True once the user actually picks a parent in this session. Until then we let the save + * read the group's live parent rather than the (possibly not-yet-loaded) prefilled value, so + * a plain rename can never re-root a subgroup just because its metadata hadn't arrived yet. */ - private var childrenGroupIds: List = emptyList() + private var parentTouched by mutableStateOf(false) var pickedMedia by mutableStateOf(null) private set @@ -150,12 +150,12 @@ class RelayGroupMetadataViewModel : ViewModel() { // Stored geohashes are mip-mapped into every prefix; the last (longest) is the real one. geohash.value = TextFieldValue(event?.geohashes()?.maxByOrNull { it.length } ?: "") parentGroupId = channel.parentGroupId() - childrenGroupIds = channel.childGroupIds() } /** Set (or clear, with null) the group's parent from the picker; marks the form touched. */ fun setParent(groupId: String?) { parentGroupId = groupId + parentTouched = true markTouched() } @@ -258,8 +258,11 @@ class RelayGroupMetadataViewModel : ViewModel() { isRestricted = isRestricted, hashtags = hashtags, geohashes = geohashes, - parent = parentGroupId, - children = childrenGroupIds, + // Only override the parent when the user actually re-parented; otherwise let + // Account read the group's live parent so a rename can't accidentally re-root it. + // children likewise defaults to the live child list, so a concurrently-added + // subgroup isn't dropped by this metadata edit. + parent = if (parentTouched) parentGroupId else existing.parentGroupId(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt index 5bb4c35dd6..f3760e4216 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -136,14 +137,18 @@ private fun ParentSelectorCard( accountViewModel: AccountViewModel, onClick: () -> Unit, ) { - val parentChannel = - remember(parentId, relay) { - parentId?.let { accountViewModel.getRelayGroupChannelIfExists(GroupId(it, relay)) } + // Resolve the parent (get-or-create so it's never stuck null when the metadata isn't cached + // yet), warm its single 39000, and observe it so the name/picture fill in as they arrive. + val liveParent: RelayGroupChannel? = + parentId?.let { id -> + val channel = remember(id, relay) { accountViewModel.checkGetOrCreateRelayGroupChannel(GroupId(id, relay)) } + RelayGroupWarmupSubscription(channel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel) + val state by channel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + state.channel as? RelayGroupChannel ?: channel } - // Warm the parent's metadata so its name/picture fills the card while the form is open. - parentChannel?.let { - RelayGroupWarmupSubscription(it, accountViewModel.dataSources().relayGroupWarmup, accountViewModel) - } Surface( shape = RoundedCornerShape(18.dp), @@ -160,11 +165,11 @@ private fun ParentSelectorCard( horizontalArrangement = Arrangement.spacedBy(14.dp), ) { GradientBadge { - if (parentId != null && parentChannel != null) { + if (liveParent != null) { RobohashFallbackAsyncImage( - robot = parentChannel.groupId.id, - model = parentChannel.profilePicture(), - contentDescription = parentChannel.toBestDisplayName(), + robot = liveParent.groupId.id, + model = liveParent.profilePicture(), + contentDescription = liveParent.toBestDisplayName(), modifier = Modifier.size(46.dp).clip(CircleShape), loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), @@ -172,7 +177,7 @@ private fun ParentSelectorCard( ) } else { Icon( - symbol = if (parentId == null) MaterialSymbols.Home else MaterialSymbols.Group, + symbol = MaterialSymbols.Home, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(24.dp), @@ -187,12 +192,7 @@ private fun ParentSelectorCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = - when { - parentId == null -> stringRes(R.string.relay_group_parent_none) - parentChannel != null -> parentChannel.toBestDisplayName() - else -> parentId - }, + text = liveParent?.toBestDisplayName() ?: stringRes(R.string.relay_group_parent_none), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, @@ -255,9 +255,11 @@ private fun ParentGroupPickerSheet( descendantIdsOf(accountViewModel, selfGroupId, relay) + selfGroupId } - // Re-read the relay's genuine, relay-signed groups whenever a kind-39000 lands. + // Re-read the relay's genuine, relay-signed groups whenever a kind-39000 lands. The initial + // value is empty (cheap) rather than an eager scan — a produceState initial arg is evaluated + // on every recomposition (e.g. each search keystroke), so the scan lives only in the producer. val candidates by produceState( - initialValue = pickCandidates(accountViewModel, relay, relayInfo, forbidden), + initialValue = emptyList(), relay, relayInfo, forbidden, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt index 2e3f13ade3..00b32fda10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupSubgroupsBar.kt @@ -49,7 +49,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -75,6 +75,11 @@ fun RelayGroupSubgroupsBar( val relay = channel.groupId.relayUrl + // Load the parent's + every child's kind-39000 (for their names) with ONE relay-directory + // subscription rather than a warm-up per chip — a single REQ that streams metadata for all + // of the relay's groups, versus up to N subscriptions that would each also pull content. + RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel) + Surface( color = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), modifier = Modifier.fillMaxWidth(), @@ -120,8 +125,8 @@ fun RelayGroupSubgroupsBar( } /** - * A single tappable group chip. Resolves the group by id on the shared host relay and warms - * its metadata so the name fills in, then navigates to that group when tapped. + * A single tappable group chip. Resolves the group by id on the shared host relay (its metadata + * is streamed by the bar's one directory subscription) and navigates to that group when tapped. */ @Composable private fun SubgroupChip( @@ -131,9 +136,6 @@ private fun SubgroupChip( nav: INav, ) { LoadRelayGroupChannel(groupId, accountViewModel) { child -> - // Fetch the child's 39000 (name/picture) while this bar is visible. - RelayGroupWarmupSubscription(child, accountViewModel.dataSources().relayGroupWarmup, accountViewModel) - val childState by child .flow() .metadata.stateFlow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index b1e510c991..2605b0c13c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -225,9 +225,11 @@ class RelayGroupChannel( notes .mapNotNull { _, note -> val createdAt = note.createdAt() - if (createdAt != null && note.author?.pubkeyHex != selfPubkey) note to createdAt else null + val author = note.author + // Require a resolved author so an unlinked note can't slip past the self-exclusion + // and make us reference our own event (the very thing `previous` guards against). + if (createdAt != null && author != null && author.pubkeyHex != selfPubkey) note to createdAt else null }.sortedByDescending { it.second } - .take(50) .take(max) .map { it.first.idHex.take(8) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt index 093568b275..ac59490d4a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInvite.kt @@ -43,7 +43,9 @@ object GroupNAddrInvite { */ fun parse(suffix: String?): String? { if (suffix.isNullOrEmpty()) return null - val query = suffix.substringAfter('?', "") + // Normally the code trails the naddr as `?invite=`. Fall back to a bare + // `invite=` remainder too, in case an upstream parser strips the `?`. + val query = if ('?' in suffix) suffix.substringAfter('?') else suffix if (query.isEmpty()) return null return query diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt index fb9173a351..c18b584495 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupNAddrInviteTest.kt @@ -54,6 +54,12 @@ class GroupNAddrInviteTest { assertNull(GroupNAddrInvite.parse("?invite=")) } + @Test + fun acceptsBareInviteWithoutQuestionMark() { + // Defensive: if an upstream parser drops the `?`, a bare `invite=` still resolves. + assertEquals("abc123", GroupNAddrInvite.parse("invite=abc123")) + } + @Test fun ignoresLeadingBech32RemainderWithoutQuery() { // A plain trailing word (not a query) carries no invite. From d1ed9d071b8c9e1517075031793890098dfe6e78 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 16 Jul 2026 13:31:18 +0300 Subject: [PATCH 32/45] fix(desktop): auto-approve pending AUTH once the DM-inbox relay set loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On cold boot an AUTH-required kind:10050 DM-inbox relay usually sends its AUTH challenge before the account's own kind:10050 list has been fetched. AuthApprovalPolicy.classify reads the trusted (self-approved) relay set exactly once, so it classifies the user's OWN inbox relay as tier-2 and surfaces a manual `[Once][Always][Never]` banner. Nothing re-evaluates that pending decision when the kind:10050 list finally loads, so the user gets a spurious AUTH prompt for a relay that should have auto-signed. Extract the pending-approval set into a platform-agnostic commons/AuthApprovalRequests (add/resolve/cancelAll) and add autoApproveNowTrusted(): when the DM-inbox set updates, retroactively settle every pending prompt whose relay is now tier-1 with ONCE — sign this session, do not persist (trusted by identity, not an explicit grant). DesktopAuthCoordinator now delegates its pending set to AuthApprovalRequests and exposes onSelfApprovedRelaysChanged(); Main.kt drives it from DesktopAccountRelays.dmRelayList (the account's kind:10050 StateFlow). AuthApprovalRequestsTest reproduces the cold-boot race (red before the fix) and covers the resolve / cancelAll paths. Co-Authored-By: Claude Opus 4.8 --- .../relayClient/auth/AuthApprovalRequests.kt | 107 ++++++++++++++++ .../auth/AuthApprovalRequestsTest.kt | 121 ++++++++++++++++++ .../vitorpamplona/amethyst/desktop/Main.kt | 9 ++ .../desktop/auth/DesktopAuthCoordinator.kt | 40 +++--- 4 files changed, 259 insertions(+), 18 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequests.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequestsTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequests.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequests.kt new file mode 100644 index 0000000000..b13d10c1d8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequests.kt @@ -0,0 +1,107 @@ +/* + * 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.commons.relayClient.auth + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Holds the set of tier-2 NIP-42 AUTH challenges awaiting a decision and the + * logic that settles them. Platform-agnostic (lives in `commons`) so the state + * and, crucially, the cold-boot race fix are unit-testable without any desktop + * wiring (relay client, LocalCache, Compose). + * + * A [PendingAuthApproval] is produced by [AuthApprovalPolicy.classify] when a + * relay is neither pre-approved nor blocked; the suspended signer awaits its + * [PendingAuthApproval.decision]. This class owns the live set the banner + * renders and the three ways an entry leaves it: + * + * - [resolve] — the user picked `[Once] [Always] [Never]`. + * - [autoApproveNowTrusted] — the relay became tier-1 after the challenge was + * surfaced (the cold-boot race, see that method). + * - [cancelAll] — logout / account switch tears everything down. + */ +class AuthApprovalRequests { + private val _pending = MutableStateFlow>(persistentMapOf()) + + /** The live set of pending tier-2 challenges the AUTH banner renders. */ + val pending: StateFlow> = _pending.asStateFlow() + + /** Track a newly surfaced tier-2 challenge. Last write per relay wins. */ + fun add(approval: PendingAuthApproval) { + _pending.update { it.put(approval.relayUrl, approval) } + } + + /** + * Settle a pending challenge with the user's pick. Removes the entry + * BEFORE completing the deferred so the suspended signer wakes exactly + * once. Returns false if there was nothing pending for [relayUrl]. + */ + fun resolve( + relayUrl: NormalizedRelayUrl, + scope: AuthApprovalScope, + ): Boolean { + val approval = _pending.value[relayUrl] ?: return false + _pending.update { it.remove(relayUrl) } + return approval.decision.complete(scope) + } + + /** + * Auto-approve any pending challenge whose relay is now tier-1 (present in + * [trusted]). + * + * Fixes the cold-boot race: on startup an AUTH-required DM-inbox relay + * (kind:10050) often sends its challenge before the account's own kind:10050 + * list has been fetched, so [AuthApprovalPolicy.classify] — which reads the + * trusted set exactly once — sees an empty set and surfaces a tier-2 prompt + * for a relay that should have auto-signed. Nothing re-evaluates that + * pending decision when the list finally loads, leaving a spurious banner. + * + * When the DM-inbox set updates, call this: every pending relay now in + * [trusted] is settled with [AuthApprovalScope.ONCE] — sign this session but + * do NOT persist, since it is trusted by identity (it is the user's own + * inbox), not by an explicit user grant. + */ + fun autoApproveNowTrusted(trusted: Set) { + // Snapshot the relays to settle so we don't mutate while iterating the + // live map. ONCE = sign this session, never persist (trusted by + // identity, not by an explicit user grant). + _pending.value.keys + .filter { it in trusted } + .forEach { relayUrl -> resolve(relayUrl, AuthApprovalScope.ONCE) } + } + + /** + * Cancel every pending challenge, completing each with + * [AuthApprovalScope.BLOCKED] so suspended signers unblock (and drop the + * AUTH) rather than hang. Used on logout / account switch. + */ + fun cancelAll() { + val snapshot = _pending.value + _pending.value = persistentMapOf() + snapshot.values.forEach { it.decision.complete(AuthApprovalScope.BLOCKED) } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequestsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequestsTest.kt new file mode 100644 index 0000000000..1f8a821be1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalRequestsTest.kt @@ -0,0 +1,121 @@ +/* + * 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.commons.relayClient.auth + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AuthApprovalRequestsTest { + private val ownInbox = NormalizedRelayUrl("wss://my.inbox.relay/") + private val someOtherRelay = NormalizedRelayUrl("wss://random.relay/") + + private fun pendingFor(relay: NormalizedRelayUrl): CompletableDeferred { + val deferred = CompletableDeferred() + return deferred + } + + /** + * Reproduces the cold-boot tier-1 race. + * + * A relay challenges for AUTH before the account's kind:10050 has loaded, + * so it is surfaced as a tier-2 pending prompt. When kind:10050 finally + * loads and the relay turns out to be the user's own DM inbox (tier-1), the + * spurious prompt must clear and the suspended signer must be released to + * sign — without persisting anything (ONCE), because it is trusted by + * identity, not by an explicit user grant. + * + * RED until [AuthApprovalRequests.autoApproveNowTrusted] is implemented. + */ + @Test + fun pendingChallengeForNowTrustedRelayIsAutoApproved() = + runTest { + val requests = AuthApprovalRequests() + val deferred = pendingFor(ownInbox) + requests.add(PendingAuthApproval(ownInbox, deferred)) + assertTrue(requests.pending.value.containsKey(ownInbox)) + + // kind:10050 arrives: the challenging relay is now tier-1. + requests.autoApproveNowTrusted(setOf(ownInbox)) + + assertFalse( + requests.pending.value.containsKey(ownInbox), + "spurious banner must clear once the relay is known to be trusted", + ) + assertTrue(deferred.isCompleted, "the suspended signer must be released") + assertEquals( + AuthApprovalScope.ONCE, + deferred.await(), + "tier-1 auto-approval must sign but NOT persist (ONCE)", + ) + } + + @Test + fun pendingChallengeForUntrustedRelayIsLeftForTheUser() = + runTest { + val requests = AuthApprovalRequests() + val deferred = pendingFor(someOtherRelay) + requests.add(PendingAuthApproval(someOtherRelay, deferred)) + + // A different relay became trusted; this one is still unknown. + requests.autoApproveNowTrusted(setOf(ownInbox)) + + assertTrue( + requests.pending.value.containsKey(someOtherRelay), + "an untrusted relay's prompt must remain for the user to decide", + ) + assertFalse(deferred.isCompleted, "the untrusted signer must stay suspended") + } + + @Test + fun resolveRemovesEntryAndCompletesDeferred() = + runTest { + val requests = AuthApprovalRequests() + val deferred = pendingFor(someOtherRelay) + requests.add(PendingAuthApproval(someOtherRelay, deferred)) + + assertTrue(requests.resolve(someOtherRelay, AuthApprovalScope.BLOCKED)) + + assertFalse(requests.pending.value.containsKey(someOtherRelay)) + assertEquals(AuthApprovalScope.BLOCKED, deferred.await()) + assertFalse(requests.resolve(someOtherRelay, AuthApprovalScope.ONCE), "second resolve is a no-op") + } + + @Test + fun cancelAllReleasesEverySignerWithBlocked() = + runTest { + val requests = AuthApprovalRequests() + val a = pendingFor(ownInbox) + val b = pendingFor(someOtherRelay) + requests.add(PendingAuthApproval(ownInbox, a)) + requests.add(PendingAuthApproval(someOtherRelay, b)) + + requests.cancelAll() + + assertTrue(requests.pending.value.isEmpty()) + assertEquals(AuthApprovalScope.BLOCKED, a.await()) + assertEquals(AuthApprovalScope.BLOCKED, b.await()) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index a056f545df..ec33ae1ff2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1367,6 +1367,15 @@ private fun AppInner( remember(account, relayManager, scope) { DesktopAccountRelays(account.pubKeyHex, relayManager, scope) } + // Cold-boot AUTH race fix: when the account's own kind:10050 DM-inbox + // set loads (often AFTER an inbox relay has already challenged for + // AUTH), retroactively auto-approve any pending tier-2 prompt for a + // relay that is actually tier-1, instead of leaving a spurious banner. + LaunchedEffect(authCoordinator, accountRelays) { + accountRelays.dmRelayList.collect { dmInbox -> + authCoordinator.onSelfApprovedRelaysChanged(dmInbox) + } + } val iAccount = remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) { DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt index fa06c42d16..f00fe83cc7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.auth import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalDecision import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalPolicy +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalRequests import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalStore import com.vitorpamplona.amethyst.commons.relayClient.auth.PendingAuthApproval @@ -34,12 +35,8 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.PersistentMap -import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update /** * Desktop NIP-42 AUTH wiring. @@ -79,13 +76,13 @@ class DesktopAuthCoordinator( @Volatile private var active: ActiveAuth? = null - private val _pendingApprovals = MutableStateFlow>(persistentMapOf()) + private val requests = AuthApprovalRequests() /** * Tier-2 AUTH challenges awaiting the user's `[Once] [Always] [Never]` * decision. The banner UI subscribes and calls [resolve] to settle each. */ - val pendingApprovals: StateFlow> = _pendingApprovals.asStateFlow() + val pendingApprovals: StateFlow> = requests.pending /** Wire AUTH for a newly logged-in account. Idempotent. */ fun onLogin(account: AccountState.LoggedIn) { @@ -97,9 +94,7 @@ class DesktopAuthCoordinator( AuthApprovalPolicy( selfApprovedRelays = { selfApprovedRelaysFor(account.pubKeyHex) }, store = store, - onPromptRequired = { pending -> - _pendingApprovals.update { it.put(pending.relayUrl, pending) } - }, + onPromptRequired = { pending -> requests.add(pending) }, ) val authenticator = RelayAuthenticator( @@ -121,26 +116,35 @@ class DesktopAuthCoordinator( } /** - * Resolve a tier-2 [PendingAuthApproval] from the banner UI. - * - * Removes the entry from [pendingApprovals] before completing the - * deferred, so the suspended signer wakes up exactly once. + * Resolve a tier-2 [PendingAuthApproval] from the banner UI. The suspended + * signer wakes up exactly once with the user's pick. */ fun resolve( relayUrl: NormalizedRelayUrl, scope: AuthApprovalScope, ) { - val pending = _pendingApprovals.value[relayUrl] ?: return - _pendingApprovals.update { it.remove(relayUrl) } - pending.decision.complete(scope) + requests.resolve(relayUrl, scope) + } + + /** + * Called when the active account's NIP-17 DM-inbox (kind:10050) set loads + * or changes. Auto-approves any pending tier-2 prompt whose relay is now in + * that set, fixing the cold-boot race where an own inbox relay challenges + * for AUTH before kind:10050 has been fetched and gets a spurious prompt + * that nothing would otherwise re-evaluate. + * + * [trusted] must be the strict kind:10050 inbox set (same tier-1 source as + * [selfApprovedRelaysFor]) — never the lenient NIP-65 fallback. + */ + fun onSelfApprovedRelaysChanged(trusted: Set) { + requests.autoApproveNowTrusted(trusted) } private fun tearDownLocked() { val prev = active ?: return prev.authenticator.destroy() // Cancel any in-flight tier-2 prompts so suspended signers wake up. - _pendingApprovals.value.values.forEach { it.decision.complete(AuthApprovalScope.BLOCKED) } - _pendingApprovals.value = persistentMapOf() + requests.cancelAll() active = null } From 37220306e9858bbed0d30d7fd9d53a66d146eb59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 13:18:51 +0000 Subject: [PATCH 33/45] fix(nip29): reject non-relay-signed group state; share one-tap invite links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authorization hardening: - LocalCache only applies a kind 39000-39005 addressable (group metadata, admins, members, roles, pins) to a group's state when the event is signed by the relay's own NIP-11 `self` key. Previously any author's 39001 served for a group id was applied (newest wins), so a stray/malicious user-published admin list on a lax relay could inject itself as admin in the client's view and unlock moderation UI. The guard only blocks when `self` is known and differs, so legitimate groups (and relays whose NIP-11 hasn't loaded) are unaffected. Moderation events (9000-9010) were already stored-but-not-applied — the client relies on the relay-republished 39001/39002 — so they need no change. Invite links: - The invite dialog now produces the spec's single `naddr1…?invite=` link for closed groups and copies just that, so one tap joins. It previously copied the naddr and code as two separate lines, which the new parser (that reads `?invite=`) could never round-trip. spotless clean; amethyst compiles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S --- .../amethyst/model/LocalCache.kt | 31 ++++++++++++++++--- .../relayGroup/InviteRelayGroupDialog.kt | 25 +++++++++------ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index bbbbc2d2d4..1a5c583ad6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1853,6 +1853,27 @@ object LocalCache : ILocalCache, ICacheProvider { return new } + /** + * NIP-29 addressables (kinds 39000-39005) are authoritative for a group's metadata, + * roster, roles and pins ONLY when signed by the relay's own key — the NIP-11 `self` + * pubkey. This returns false only when we can positively tell an event is NOT relay-signed + * (the relay advertises a `self` and the event's author differs), so a stray or malicious + * user-published 39000/39001/… served by a lax relay can't overwrite a group's state (e.g. + * inject itself into the admin list). When `self` isn't known yet — the NIP-11 doc hasn't + * loaded, or the relay doesn't advertise one — we don't block, so legitimate groups still + * populate and this never regresses a relay whose key we simply haven't fetched. + */ + private fun isRelaySignedGroupEvent( + event: Event, + relay: NormalizedRelayUrl, + ): Boolean { + val self = + Amethyst.instance.nip11Cache + .getFromCache(relay) + .self ?: return true + return event.pubKey == self + } + /** * NIP-29 relay-signed group metadata (kind 39000). Stored as an addressable * note and used to populate the [RelayGroupChannel]'s name/picture/about/ @@ -1868,7 +1889,7 @@ object LocalCache : ILocalCache, ICacheProvider { ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val note = getOrCreateAddressableNote(event.address()) val channel = getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay)) (note.event as? GroupMetadataEvent)?.let { channel.updateGroupInfo(it, note) } @@ -1884,7 +1905,7 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? GroupMembersEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateMembers(it) } } @@ -1898,7 +1919,7 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? GroupAdminsEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateAdmins(it) } } @@ -1912,7 +1933,7 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? GroupPinnedEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updatePinned(it) } } @@ -1926,7 +1947,7 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? SupportedRolesEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateSupportedRoles(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt index e89cd689e1..16c210e033 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt @@ -67,11 +67,18 @@ fun InviteRelayGroupDialog( .collectAsStateWithLifecycle() val liveChannel = channelState.channel as? RelayGroupChannel ?: channel - // A shareable, cross-client coordinate for the group (opens the chat in any - // NIP-29 client). Null until the relay-signed metadata has loaded. - val nAddr = liveChannel.toNAddr()?.let { "nostr:$it" } val isClosed = liveChannel.isClosed() + // A shareable, cross-client coordinate for the group (opens the chat in any NIP-29 + // client). Null until the relay-signed metadata has loaded. For a closed (invite-only) + // group we append the one-time code as the spec's `?invite=` suffix so the whole + // link is a single tap that auto-joins — no separate code to paste. + val nAddr = + liveChannel.toNAddr()?.let { base -> + val uri = "nostr:$base" + if (isClosed) "$uri?invite=$code" else uri + } + // A join code is only meaningful for closed (invite-only) groups; open groups // join directly from the shared naddr. So mint the kind-9009 invite only when // the group is actually closed, rather than on every dialog open. @@ -116,15 +123,13 @@ fun InviteRelayGroupDialog( } }, confirmButton = { - // Copy the group link, plus the code when the group is closed (so a - // recipient has both to join). Never fall back to copying a code the - // dialog didn't show — for an open group with metadata not yet loaded - // there is simply nothing to copy, so disable the button. - val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n") + // Copy the single shareable link. For a closed group the code is already + // embedded as `?invite=…`, so one tap joins. Nothing to copy until the + // group's metadata (and thus the naddr) has loaded, so disable until then. TextButton( - enabled = toCopy.isNotBlank(), + enabled = nAddr != null, onClick = { - scope.launch { clipboard.setText(toCopy) } + nAddr?.let { link -> scope.launch { clipboard.setText(link) } } onDismiss() }, ) { From 9d0cc8ad566a9a3f4e6908f5b9c510e55a5d45df Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:40:26 +0000 Subject: [PATCH 34/45] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 104 ++++++++++++++++- .../src/main/res/values-hu-rHU/strings.xml | 108 +++++++++++++++++- .../src/main/res/values-zh-rCN/strings.xml | 5 + docs/changelog/translators.json | 12 +- 4 files changed, 217 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 1add460547..d59844c37f 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -97,6 +97,7 @@ सुरक्षा छलनियाँ निर्गमनांकन अधिक दिखाएँ + अल्प दिखाएँ लैटनिंग चालान समयसीमा समाप्त क्लिंक॰ प्रस्ताव @@ -297,6 +298,9 @@ लेखा जानकारी प्राप्त की जा रही है आमन्त्रण चुकाव… आमन्त्रण अप्राप्त। सम्भाव्यतः योजक निष्क्रिय है अथवा इसके पुनःप्रसारक अनभिगम्य। + यह आमन्त्रण योजक अमान्य है अथवा इस लेखा द्वारा खोला नहीं जा सकता। + यह आमन्त्रण बनाया गया एक नवीनतर क्रमक संस्करण से तथा यहाँ नहीं खोला जा सकता अभी। एक अद्यतनकृत योजक के लिए पूछें अथवा अद्यतन पश्चात पुनःप्रयास करें। + यह आमन्त्रण योजक निराकृत है तथा अनुपयुक्तव्य। नए के लिए पूछें। कोंकोर्ड॰ प्रणाली आप किसी भी कोंकोर्ड प्रणाली से जुडे नहीं अब तक। एक बनाएँ अथवा आमन्त्रण योजक खोलें। कोई प्रणाली नहीं अब तक। @@ -329,6 +333,11 @@ %1$d सदस्य %1$d सदस्य + + %1$d नया सन्देश + %1$d नए सन्देश + + कोई सन्देश नहीं अब तक बनाएँ लोगों को आमन्त्रण आमन्त्रण योजक @@ -1861,6 +1870,63 @@ नया निजी अथवा सार्वजनिक झुण्ड + एक वार्तालाप आरम्भ करें + इस के लिए सबसे अच्छा + अच्छा + समझौते + निजी + रहस्यीकृत समूह + पुनःप्रसारक अवगत + निजी सन्देश + सीधे सन्देश आरम्भ से अन्त तक रहस्यीकृत + एक प्रति एक। पाँच शालाएँ + एक प्रति एक तथा लघु समूह चर्चाएँ आपके परिचित लोगों के साथ। + नया सन्देश + एक प्रति एक तथा लघु समूह + आरम्भ से अन्त तक रहस्यीकृत + तत्पश्चात प्रयोक्ताओं को नहीं जोड सकते + मार्मोट॰ समूह + बृहत्तर निजी समूह + यन्त्रबद्ध + बृहत्तर निजी समूह जो लघुसमूह सीधेसन्देश से उद्भूत हैं। + समूह बनाएँ + लघु सीधेसन्देश मापदण्डस्तर पार करता है + रहस्यीकृत समूह चर्चा + यन्त्रबद्ध। अदृश्य यदि आप अन्यत्र प्रवेशांकन करें + कोंकोर्ड समुदाय + समुदाय प्रणाली अनुसार विभाजित + कार्यशालाएँ + विशाल समुदाय। प्रणालियों अथवा कार्यप्रवाहों अनुसार व्यवस्थित। + समुदाय बनाएँ + विशाल समुदायों तक का मापदण्डस्तर + प्रणाली अथवा कार्यप्रवाह अनुसार विभाजित + स्थापनार्थ और अधिक + सार्वजनिक चर्चा + सार्वजनिक सन्देश एक पुनःप्रसारक पर + नियमनरहित + खुले सार्वजनिक शालाएँ जहाँ कोई भी पत्र प्रकाशित कर सकता है। + नयी सार्वजनिक चर्चा बनाएँ + कोई भी जुड सकता है + सरल तथा आविष्करणशक्य + केवल सार्वजनिक + नियमनरहित + पुनःप्रसारक समूह + पुनःप्रसारक संचालित। सार्वजनिक अथवा निजी + नियमनयुक्त + अपने निवास पुनःप्रसारक द्वारा नियमनकृत समूह। + पुनःप्रसारक समूह वीक्षण + पुनःप्रसारक द्वारा नियमनकृत + सार्वजनिक अथवा निजी + उस एक पुनःप्रसारक से बद्ध + नश्वर चर्चा + जो भी संयोजित हो। अभी इस समय + वर्तमान अभी + सामयिक चर्चा किसी से भी जो अभी संयोजित हो। + चर्चा आरम्भ करें + जो भी संयोजित हो अभी उनसे बात करें + कुछ भी अभिलेखित नहीं + कोई इतिहास नहीं + पुनःप्रसारक सीमित पुनःप्रसारक समूह पंक्ति में पुनःप्रसारक अनुसार @@ -1905,6 +1971,10 @@ सदस्य सूची समूह सम्पादन सूत्र + सन्देश टाँकें + सन्देश से टाँका हटाएँ + टँकित + टँकित सन्देश समूह खोलें इस पुनःप्रसारक पर कोई समूह नहीं अब तक। यह पुनःप्रसारक समूहों का अवलम्बन करने का विज्ञापन नहीं करता (निप॰२९)। इसलिए सम्भावना है कि कोई समूह यहाँ उपलब्ध नहीं। @@ -2039,6 +2109,8 @@ ब्लोस्सम॰ क्रमक प्राप्त नहीं। कृपया एक स्थानीय ब्लोस्सम॰ क्रमक की स्थापना करें इस अभिलेख को देखने के लिए छिपाए गये शब्द नया शब्द अथवा वाक्य छिपाएँ + विषयसूचक मौन करें + विषयसूचक मौन अवस्था हटाएँ मौनकृत सूत्र कोई मौनकृत सूत्र नहीं अज्ञात सूत्र। %1$s @@ -2565,8 +2637,23 @@ निचली मार्गदर्शन पट्टी क्रम पुनःव्यवस्थित करने के लिए खींचें। विकल्पान्तरण करें वस्तु को जोडने अथवा हटाने के लिए निचली पट्टी से। शून्य वस्तु हो तो निचली पट्टी अदृश्य रहेगी। उपलब्ध + आपकी निचली पट्टी + कोई वस्तु टँकित नहीं। निचली पट्टी अदृश्य रहेगा जब तक कि आप न्यूनतम एक को जोडते नहीं। क्रम पुनःव्यवस्था + विकल्प दिखाएँ + कोई प्रियचिह्नित नहीं। एक क्रमक को ताराचिह्नित करें जालसंचारक में यहाँ जोडने के लिए। + कोई समूह नहीं जिससे जुडे हुए हैं अब तक। + खीचें पुनःव्यवस्थित करने के लिए। व्यत्यस्तचिह्न दबाएँ हटाने के लिए + जोडें + जोडा गया + हटाएँ मूलविकल्प पुनःस्थापित करें + मुख्‍य + चर्चाएँ तथा समूह + आप + सूचनावलियाँ + क्रमक तथा जाल + अन्य मुख्यपटल पृष्ठसूचक चयन करें किन पृष्ठसूचक दिखने चाहिए मुख्यपटल पर। जब एक ही सक्रिय है तब पृष्ठसूचक पट्टी छुपाई जाएगी। सभी @@ -2636,6 +2723,15 @@ स्थान हटाएँ विषयवस्तु चेतावनी जोडें विषयवस्तु चेतावनी हटाएँ + %1$s ने %2$s प्रणाली बनाया + %1$s ने प्रणाली बनाया + %1$s ने प्रणाली परिचय का अद्यतन किया + सन्देश वितरण + आवृत + किसी एक पुनःप्रसारक द्वारा इस सन्देश की स्वीकृति की प्रतीक्षा + इस सन्देश के लिए कोई पुनःप्रसारक जानकारी नहीं + न्यूनतम एक पुनःप्रसारक द्वारा स्वीकृत + सभी प्राप्तकर्ताओं के पुनःप्रसारकों को भेजा गया समापन दिनांक जोडें समापन दिनांक हटाएँ समापन दिनांक @@ -2963,7 +3059,7 @@ क्या आप निकटकालिक क्रमदोष सूचनापत्र एक सीधे सन्देश में अमेथिस्ट को भेजना चाहते हैं। कोई व्यक्तिगत जानकारी बाँटी नहीं जाएगी भेजें क्रमक संसाधन उपयोग - विद्युत्कोष तथ्य उपयोग उपभोग ऊर्जा जाल निदान लेखापुस्तक + विद्युत्कोष तथ्य उपयोग उपभोग ऊर्जा जाल निदानतन्त्र लेखापुस्तक आज पिछले सात दिन चलनशील आज @@ -3291,7 +3387,7 @@ सेवासंगणकों का परीक्षण… संयोजित परीक्षा परिणाम - निदानार्थ लक्षण + निदानतन्त्र अन्तिम परीक्षा यन्त्र जानकारी टीएलएस॰ जानकारी @@ -3803,6 +3899,10 @@ समूह नाम प्रविष्ट करें समूह विवरण प्रविष्ट करें (विकल्पात्मक) परिवर्तनों का स्थायी अभिलेखन समूह में किया जाएगा एमएलएस॰ द्वारा तथा सभी सदस्यों तक प्रसारित किया जाएगा। + समूह चिह्न + चित्र जोडें + चित्र परिवर्तन + चित्र हटाएँ समूह विवरण का अद्यतन किया गया अद्यतन असफल : %1$s समूह बनाने में असफल : %1$s diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index ff0ac76ec8..5164ba98d6 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -97,6 +97,7 @@ Biztonsági szűrők Kijelentkezés Továbbiak megjelenítése + Kevesebb megjelenítése Lightning-számla Lejárt CLINK ajánlat @@ -298,6 +299,9 @@ Fiók betöltése… Meghívó beváltása… Nem sikerült lekérni ezt a meghívót. Lehet, hogy a hivatkozás lejárt, vagy az átjátszói nem érhetők el. + Ez a meghívási hivatkozás érvénytelen, vagy nem nyitható meg ezzel a fiókkal. + Ez a meghívó az alkalmazás egy újabb verziójával készült, és itt még nem nyitható meg. Kérjen egy frissített linket, vagy a frissítés után próbálja újra. + Ezt a meghívási hivatkozást visszavonták, és már nem használható. Kérjen egy újat. Concord-csatornák Még nem csatlakozott egyetlen Concord-csatornához sem. Hozzon létre egyet, vagy nyisson meg egy meghívóhivatkozást. Még nincsenek csatornák. @@ -330,6 +334,11 @@ %1$d tag %1$d tag + + %1$d új üzenet + %1$d új üzenet + + Még nincsenek üzenetek Létrehozás Tagok meghívása Meghívóhivatkozás @@ -401,7 +410,7 @@ rövid élettartamú, ezért a csevegési üzenetek idővel eltűnnek Nyilvános csevegés Új nyilvános csevegés létrehozása - MLS-csoport + Marmot-csoport Még nincs üzenet Nyilvános csevegés metaadatai A nyilvános csevegések mindenki számára láthatóak a Nostr-on, és bárki @@ -896,7 +905,7 @@ %1$s és mások Privát üzenet küldése neki: Értesítendő: - Bejegyzések letöltése tőle: + Profiladatok, bejegyzések és interakciók letöltése tőle: Közzététel ebben a szobában Szoba megnyitása Átjátszó használata @@ -908,7 +917,7 @@ Átjátszónkénti felülírások Elfelejtés Utoljára használva: %1$s - Itt még nincs semmi. A globális szabályzat minden átjátszóra vonatkozik. + Itt még nincs semmi. A globális szabály minden átjátszóra vonatkozik. Engedélyezés Megtagadás Forrás: %1$s @@ -1862,6 +1871,63 @@ Új nyilvános vagy privát csoport + Beszélgetés indítása + Legalkalmasabb ehhez: + Előnyök + Kompromisszumok + Privát + Titkosított csoportok + Átjátszó-tudatos + Privát üzenet + Közvetlen üzenetek, végpontok közötti titkosítással + 1:1-5 szoba + Személyes és kiscsoportos beszélgetések olyan emberekkel, akiket ismer. + Új üzenet + 1-1 fős és kiscsoportos + Végpontok közötti titkosítás + Később nem adhatók hozzá felhasználók + Marmot csoport + Nagyobb privát csoportok + Eszközhöz kötött + Nagyobb privát csoportok, amelyek kinőtték a kiscsoportos közvetlen üzeneteket. + Csoport létrehozása + Túlmutat a kis közvetlen üzeneteken + Titkosított csoportos csevegés + Eszközhöz kötött - nem fog megjelenni, ha máshol jelentkezik be + Concord közösség + Csatornákra bontott közösségek + Munkaterületek + Hatalmas közösségek, csatornákba vagy munkafolyamatokba szervezve. + Közösség létrehozása + Hatalmas közösségekre skálázva + Csatornára / munkafolyamatra osztott + Több beállítást igényel + Nyilvános csevegés + Nyilvános üzenetek egy átjátszón + Moderálatlan + Nyitott, nyilvános szobák, ahová bárki küldhet üzenetet. + Nyilvános csevegés létrehozása + Bárki csatlakozhat + Egyszerű és felfedezhető + Csak nyilvános + Nincs moderáció + Átjátszó-csoport + Átjátszó által futtatott, nyilvános vagy privát + Moderált + Az őket kiszolgáló átjátszó által moderált csoportok. + Átjátszó-csoportok böngészése + Átjátszó által moderált + Nyilvános vagy privát + Ahhoz az egy átjátszóhoz kötött + Eltűnő csevegés + Bárki, aki éppen online van + Most élő + Pillanatnyi csevegés bárkivel, aki éppen online van. + Csevegés indítása + Beszélgetés azokkal, akik most online vannak + Semmi sem mentődik el + Nincs előzmény + Átjátszóra korlátozott Átjátszócsoportok Beágyazott Átjátszó szerint @@ -1906,6 +1972,10 @@ Tagok Csoport szerkesztése Szálak + Üzenet kitűzése + Üzenet kitűzésének megszüntetése + Kitűzött + Kitűzött üzenetek Csoport megnyitása Még nincsenek csoportok ezen az átjátszón. Ez az átjátszó nem jelzi a csoportok támogatását (NIP-29), így előfordulhat, hogy nem futtat egyet sem. @@ -2040,6 +2110,8 @@ Nem található Blossom-alkalmazás. Telepítsen egy helyi Blossom-alkalmazást a fájl megtekintéséhez Rejtett szavak Új szó vagy mondat elrejtése + Kulcsszó némítása + Kulcsszó némításának megszüntetése Némított szálak Nincsenek némított szálak Ismeretlen szál · %1$s @@ -2564,10 +2636,25 @@ Függőben lévő megerősítés Gyors reakciók megváltoztatása Alsó navigációs sáv - Húzással rendezheti át az elemek sorrendjét. A kapcsolóval elemeket adhat hozzá az alsó sávhoz, illetve eltávolíthat onnan. Ha nincs elem, az alsó sáv el van rejtve. + A kitűzött elemeket húzással rendezheti át. Nyissa meg az alatta lévő kategóriát, és kapcsolja be vagy ki az elemet annak hozzáadásához vagy eltávolításához. Nyissa meg a „Böngésző” vagy egy csevegéstípust, hogy kedvencet vagy egy adott csoportot kitűzzün. Ha nincs elem, az alsó sáv el van rejtve. Elérhető + Saját alsó sáv + Nincsenek kitűzött elemek. Az alsó sáv rejtve marad, amíg hozzá nem ad legalább egyet. Újrarendezés + Beállítások megjelenítése + Még nincsenek kedvencek. Jelöljön meg csillaggal egy alkalmazást a böngészőben, hogy hozzáadja ide. + Még nincsenek csatlakozott csoportok. + Húzza a rendezéshez, koppintson az ✕ gombra az eltávolításhoz + Hozzáadás + Hozzáadva + Eltávolítás Alapértelmezés visszaállítása + Fő + Csevegések és csoportok + Ön + Hírfolyamok + Alkalmazások és web + Egyéb Kezdőlap lapjai Válassza ki, mely lapok jelenjenek meg a kezdőlapon. Ha csak egy lap aktív, akkor a lapsáv rejtett. Minden @@ -2637,6 +2724,15 @@ Helyszín eltávolítása Tartalmi figyelmeztetés hozzáadása Tartalmi figyelmeztetés eltávolítása + %1$s létrehozta a(z) %2$s nevű csatornát + %1$s létrehozta a csatornát + %1$s frissítette a csatorna profilját + Üzenet kézbesítése + Bezárás + Várakozás egy átjátszóra az üzenet elfogadásához + Nincs átjátszó-információ ehhez az üzenethez + Legalább egy átjátszó elfogadta + Kézbesítve az összes címzett átjátszójára Lejárati dátum hozzáadása Lejárati dátum törlése Lejárati dátum @@ -3804,6 +3900,10 @@ Adja meg a csoport nevét Adja meg a csoport leírását (nem kötelező) A változtatások az MLS-en keresztül kerülnek érvényesítésre a csoportban, és továbbítódnak minden tag felé. + Csoport ikonja + Fotó hozzáadása + Fotó módosítása + Fotó eltávolítása Csoportadatok frissítve Nem sikerült frissíteni: %1$s Nem sikerült létrehozni a csoportot: %1$s diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index e66c06f5f6..102a38a788 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -295,6 +295,9 @@ 正在载入帐户 兑换邀请中… 无法获取此邀请。链接可能已过期或其中继不可访问。 + 此邀请链接无效或无法用此账户打开。 + 此邀请是用新版本的应用创建,尚无法在此处打开。 询问已更新的链接,或在更新后重试。 + 此邀请链接已被撤销,不能再使用。请求新链接。 Concord 频道 您尚未加入任何Concord频道。创建一个频道,或者打开一个邀请链接。 尚无频道。 @@ -2080,6 +2083,8 @@ 找不到Blossom应用。请安装本地Blossom应用查看此文件 隐藏单词 隐藏新单词或句子 + 静音话题标签 + 取消静音话题标签 静音的主题帖 无静音的主题帖 未知主题帖 · %1$s diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 69409e1e16..b56145cbb7 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -134,18 +134,18 @@ "Hindi" ] }, - { - "user": "hypnotichemionus4", - "languages": [ - "Chinese Simplified" - ] - }, { "user": "summoner001", "languages": [ "Hungarian" ] }, + { + "user": "hypnotichemionus4", + "languages": [ + "Chinese Simplified" + ] + }, { "user": "StellarStoic", "languages": [ From 7143d81e159b0f42510957d2a2142b0f44024cc3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 16 Jul 2026 10:02:45 -0400 Subject: [PATCH 35/45] Activating parallel sync from the new AGP --- gradle.properties | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 050eafd47c..0c0adeb89d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,4 +30,6 @@ kotlin.daemon.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=2g kotlin.mpp.applyDefaultHierarchyTemplate=false # This is needed for the Swift bridge to work. -kotlin.mpp.enableCInteropCommonization=true \ No newline at end of file +kotlin.mpp.enableCInteropCommonization=true +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true From 94c9dc7f77528e572aa3889abb283a03b0c9e50a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 15:23:25 +0000 Subject: [PATCH 36/45] feat: parse relay LIMITS message (NIP relay limits) Adds support for the relay-to-client LIMITS frame, which advertises the connection's current rights and limits (can_read/can_write, auth requirements, max_message_length, max_subscriptions, max_filters, max_limit, POW, rate limits, required tags, etc.). Relays such as wss://pipe.imwald.eu/ send it on connect and whenever rights change, and Amethyst previously logged it as an unsupported message. - New LimitsMessage model in quartz commonMain with every optional field. - Jackson decode/encode (LimitsDeserializer + MessageSerializer) and the kotlinx-serialization path (LimitsKSerializer + MessageKSerializer). - Logs a summary line in RelayLogger. - Tests: production payload parse, array/tag fields, empty object, and round-trip parity between the Jackson and kotlinx codecs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- .../kotlinSerialization/LimitsKSerializer.kt | 96 +++++++++++++++++++ .../kotlinSerialization/MessageKSerializer.kt | 10 ++ .../relay/client/accessories/RelayLogger.kt | 2 + .../relay/commands/toClient/LimitsMessage.kt | 78 +++++++++++++++ .../relay/commands/RelayWireErgonomicsTest.kt | 74 ++++++++++++++ .../commands/toClient/LimitsDeserializer.kt | 56 +++++++++++ .../commands/toClient/MessageDeserializer.kt | 7 ++ .../commands/toClient/MessageSerializer.kt | 41 ++++++++ .../KotlinSerializationMapperTest.kt | 71 ++++++++++++++ 9 files changed, 435 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt new file mode 100644 index 0000000000..d4bc50164a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt @@ -0,0 +1,96 @@ +/* + * 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.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonArray +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray + +/** kotlinx-serialization codec for the NIP-22 `LIMITS` object payload. */ +object LimitsKSerializer { + fun serializeToElement(value: LimitsMessage): JsonObject = + buildJsonObject { + // Only emit the fields the relay actually set; absent limits stay absent. + value.canWrite?.let { put("can_write", it) } + value.canRead?.let { put("can_read", it) } + value.authForRead?.let { put("auth_for_read", it) } + value.authForWrite?.let { put("auth_for_write", it) } + value.acceptedEventKinds?.let { kinds -> + putJsonArray("accepted_event_kinds") { kinds.forEach { add(it) } } + } + value.blockedEventKinds?.let { kinds -> + putJsonArray("blocked_event_kinds") { kinds.forEach { add(it) } } + } + value.minPowDifficulty?.let { put("min_pow_difficulty", it) } + value.maxMessageLength?.let { put("max_message_length", it) } + value.maxSubscriptions?.let { put("max_subscriptions", it) } + value.maxFilters?.let { put("max_filters", it) } + value.maxLimit?.let { put("max_limit", it) } + value.maxEventTags?.let { put("max_event_tags", it) } + value.maxContentLength?.let { put("max_content_length", it) } + value.createdAtMsecsAgo?.let { put("created_at_msecs_ago", it) } + value.createdAtMsecsAhead?.let { put("created_at_msecs_ahead", it) } + value.filterRateLimit?.let { put("filter_rate_limit", it) } + value.publishingRateLimit?.let { put("publishing_rate_limit", it) } + value.requiredTags?.let { tags -> + putJsonArray("required_tags") { + tags.forEach { tag -> + addJsonArray { tag.forEach { part -> add(part) } } + } + } + } + } + + fun deserializeFromElement(jsonObject: JsonObject): LimitsMessage = + LimitsMessage( + canWrite = jsonObject["can_write"]?.jsonPrimitive?.boolean, + canRead = jsonObject["can_read"]?.jsonPrimitive?.boolean, + authForRead = jsonObject["auth_for_read"]?.jsonPrimitive?.boolean, + authForWrite = jsonObject["auth_for_write"]?.jsonPrimitive?.boolean, + acceptedEventKinds = jsonObject["accepted_event_kinds"]?.jsonArray?.map { it.jsonPrimitive.int }, + blockedEventKinds = jsonObject["blocked_event_kinds"]?.jsonArray?.map { it.jsonPrimitive.int }, + minPowDifficulty = jsonObject["min_pow_difficulty"]?.jsonPrimitive?.int, + maxMessageLength = jsonObject["max_message_length"]?.jsonPrimitive?.int, + maxSubscriptions = jsonObject["max_subscriptions"]?.jsonPrimitive?.int, + maxFilters = jsonObject["max_filters"]?.jsonPrimitive?.int, + maxLimit = jsonObject["max_limit"]?.jsonPrimitive?.int, + maxEventTags = jsonObject["max_event_tags"]?.jsonPrimitive?.int, + maxContentLength = jsonObject["max_content_length"]?.jsonPrimitive?.int, + createdAtMsecsAgo = jsonObject["created_at_msecs_ago"]?.jsonPrimitive?.long, + createdAtMsecsAhead = jsonObject["created_at_msecs_ahead"]?.jsonPrimitive?.long, + filterRateLimit = jsonObject["filter_rate_limit"]?.jsonPrimitive?.long, + publishingRateLimit = jsonObject["publishing_rate_limit"]?.jsonPrimitive?.long, + requiredTags = + jsonObject["required_tags"]?.jsonArray?.map { tag -> + (tag as JsonArray).map { it.jsonPrimitive.content } + }, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 800420e49c..0892e776e6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage @@ -97,6 +98,11 @@ object MessageKSerializer : KSerializer { add(JsonPrimitive(value.subId)) } + is LimitsMessage -> { + // NIP-22 wire format: ["LIMITS", { }] + add(LimitsKSerializer.serializeToElement(value)) + } + is NegMsgMessage -> { add(JsonPrimitive(value.subId)) add(JsonPrimitive(value.message)) @@ -160,6 +166,10 @@ object MessageKSerializer : KSerializer { CountMessage(queryId, result) } + LimitsMessage.LABEL -> { + LimitsKSerializer.deserializeFromElement(array[1].jsonObject) + } + NegMsgMessage.LABEL -> { NegMsgMessage( subId = array[1].jsonPrimitive.content, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index ba3c1e5a34..f1b95de2f2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage @@ -64,6 +65,7 @@ class RelayLogger( is AuthMessage -> if (debugReceiving) Log.d(logTag) { "Auth: ${msg.challenge}" } is NotifyMessage -> if (debugReceiving) Log.d(logTag) { "Notify: ${msg.message}" } is CountMessage -> if (debugReceiving) Log.d(logTag) { "Count: ${msg.result.count} approx: ${msg.result.approximate} hll: ${msg.result.hll != null}" } + is LimitsMessage -> if (debugReceiving) Log.d(logTag) { "Limits: canRead=${msg.canRead} canWrite=${msg.canWrite} maxLimit=${msg.maxLimit} maxSubscriptions=${msg.maxSubscriptions}" } is ClosedMessage -> Log.w(logTag) { "Closed: ${msg.subId} ${msg.message}" } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt new file mode 100644 index 0000000000..cb2f68f2f6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt @@ -0,0 +1,78 @@ +/* + * 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.commands.toClient + +/** + * NIP-22 `LIMITS` message: the relay advertises the current rights and limits + * for this connection. Sent upon connection and again at any point during the + * connection to reflect the client's changing rights (e.g. after a NIP-42 AUTH). + * + * Wire format: `["LIMITS", { }]`. + * + * Every field is optional: a relay only sends the limits it enforces, and a + * missing field means "unspecified / no advertised limit" — clients should keep + * any previously cached value and not assume a default. Clients cache this + * payload on the relay connection and apply it when sending `EVENT`s and `REQ`s. + */ +class LimitsMessage( + /** Whether clients may publish events to this relay. */ + val canWrite: Boolean? = null, + /** Whether clients may send `REQ` commands to this relay. */ + val canRead: Boolean? = null, + /** Whether the client must authenticate (NIP-42) before reading. */ + val authForRead: Boolean? = null, + /** Whether the client must authenticate (NIP-42) before writing. */ + val authForWrite: Boolean? = null, + /** Allowlist of event kinds the relay accepts for publishing. */ + val acceptedEventKinds: List? = null, + /** Denylist of event kinds the relay rejects for publishing. */ + val blockedEventKinds: List? = null, + /** Minimum proof-of-work (NIP-13) difficulty in bits required to publish. */ + val minPowDifficulty: Int? = null, + /** Maximum byte length for published events and `REQ` filters. */ + val maxMessageLength: Int? = null, + /** Maximum number of concurrent subscriptions allowed. */ + val maxSubscriptions: Int? = null, + /** Maximum number of filters allowed per `REQ` command. */ + val maxFilters: Int? = null, + /** Maximum value the relay honors for a filter's `limit` (use pagination for more). */ + val maxLimit: Int? = null, + /** Maximum number of tags allowed in a published event. */ + val maxEventTags: Int? = null, + /** Maximum length of the `content` field of a published event. */ + val maxContentLength: Int? = null, + /** Minimum event `created_at` recency, in milliseconds before now. */ + val createdAtMsecsAgo: Long? = null, + /** Maximum event `created_at` in the future, in milliseconds ahead of now. */ + val createdAtMsecsAhead: Long? = null, + /** Minimum debounce interval, in milliseconds, between filter changes. */ + val filterRateLimit: Long? = null, + /** Minimum debounce interval, in milliseconds, between publishes. */ + val publishingRateLimit: Long? = null, + /** Tags that must be present on published events, as `[key, optional value]` pairs. */ + val requiredTags: List>? = null, +) : Message { + override fun label() = LABEL + + companion object { + const val LABEL = "LIMITS" + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt index 2eb20459b0..d2c4499727 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -98,4 +99,77 @@ class RelayWireErgonomicsTest { assertEquals("sub1", closed.subId) assertEquals("restricted: nope", closed.message) } + + @Test + fun limitsMessageParsesProductionPayload() { + // Real payload from wss://pipe.imwald.eu/ (NIP-22 LIMITS). + val json = + """["LIMITS",{"can_read":true,"can_write":true,"auth_for_read":true,"auth_for_write":false,""" + + """"max_message_length":262144,"max_subscriptions":10,"max_filters":10,"max_limit":200,""" + + """"max_event_tags":2000,"max_content_length":131072}]""" + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertEquals(true, parsed.canRead) + assertEquals(true, parsed.canWrite) + assertEquals(true, parsed.authForRead) + assertEquals(false, parsed.authForWrite) + assertEquals(262144, parsed.maxMessageLength) + assertEquals(10, parsed.maxSubscriptions) + assertEquals(10, parsed.maxFilters) + assertEquals(200, parsed.maxLimit) + assertEquals(2000, parsed.maxEventTags) + assertEquals(131072, parsed.maxContentLength) + // Fields the relay didn't send stay null. + assertNull(parsed.minPowDifficulty) + assertNull(parsed.acceptedEventKinds) + assertNull(parsed.requiredTags) + } + + @Test + fun limitsMessageParsesArrayFields() { + val json = + """["LIMITS",{"accepted_event_kinds":[0,1,3],"blocked_event_kinds":[4],""" + + """"min_pow_difficulty":16,"created_at_msecs_ago":3600000,"created_at_msecs_ahead":60000,""" + + """"required_tags":[["t","nostr"],["p"]]}]""" + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertEquals(listOf(0, 1, 3), parsed.acceptedEventKinds) + assertEquals(listOf(4), parsed.blockedEventKinds) + assertEquals(16, parsed.minPowDifficulty) + assertEquals(3600000L, parsed.createdAtMsecsAgo) + assertEquals(60000L, parsed.createdAtMsecsAhead) + assertEquals(listOf(listOf("t", "nostr"), listOf("p")), parsed.requiredTags) + } + + @Test + fun limitsMessageRoundTrips() { + val original = + LimitsMessage( + canRead = true, + canWrite = false, + authForWrite = true, + maxLimit = 500, + acceptedEventKinds = listOf(1, 30023), + requiredTags = listOf(listOf("t", "nostr")), + ) + val json = original.toJson() + assertTrue(json.startsWith("""["LIMITS",{""")) + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertEquals(true, parsed.canRead) + assertEquals(false, parsed.canWrite) + assertEquals(true, parsed.authForWrite) + assertNull(parsed.authForRead) + assertEquals(500, parsed.maxLimit) + assertEquals(listOf(1, 30023), parsed.acceptedEventKinds) + assertEquals(listOf(listOf("t", "nostr")), parsed.requiredTags) + } + + @Test + fun limitsMessageParsesEmptyObject() { + val parsed = Message.fromJson("""["LIMITS",{}]""") + assertTrue(parsed is LimitsMessage) + assertNull(parsed.canRead) + assertNull(parsed.maxLimit) + } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt new file mode 100644 index 0000000000..fcd8e345f4 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt @@ -0,0 +1,56 @@ +/* + * 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.commands.toClient + +import com.fasterxml.jackson.databind.JsonNode + +class LimitsDeserializer { + companion object { + private fun JsonNode.intList(field: String): List? = get(field)?.takeIf { it.isArray }?.map { it.asInt() } + + private fun JsonNode.requiredTags(field: String): List>? = + get(field)?.takeIf { it.isArray }?.map { tag -> + tag.map { it.asText() } + } + + fun fromJson(jsonObject: JsonNode): LimitsMessage = + LimitsMessage( + canWrite = jsonObject.get("can_write")?.asBoolean(), + canRead = jsonObject.get("can_read")?.asBoolean(), + authForRead = jsonObject.get("auth_for_read")?.asBoolean(), + authForWrite = jsonObject.get("auth_for_write")?.asBoolean(), + acceptedEventKinds = jsonObject.intList("accepted_event_kinds"), + blockedEventKinds = jsonObject.intList("blocked_event_kinds"), + minPowDifficulty = jsonObject.get("min_pow_difficulty")?.asInt(), + maxMessageLength = jsonObject.get("max_message_length")?.asInt(), + maxSubscriptions = jsonObject.get("max_subscriptions")?.asInt(), + maxFilters = jsonObject.get("max_filters")?.asInt(), + maxLimit = jsonObject.get("max_limit")?.asInt(), + maxEventTags = jsonObject.get("max_event_tags")?.asInt(), + maxContentLength = jsonObject.get("max_content_length")?.asInt(), + createdAtMsecsAgo = jsonObject.get("created_at_msecs_ago")?.asLong(), + createdAtMsecsAhead = jsonObject.get("created_at_msecs_ahead")?.asLong(), + filterRateLimit = jsonObject.get("filter_rate_limit")?.asLong(), + publishingRateLimit = jsonObject.get("publishing_rate_limit")?.asLong(), + requiredTags = jsonObject.requiredTags("required_tags"), + ) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt index 09e31365e0..52badf4dbe 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt @@ -103,6 +103,13 @@ class MessageDeserializer : StdDeserializer(Message::class.java) { ) } + LimitsMessage.LABEL -> { + jp.nextToken() + val result: JsonNode = jp.codec.readTree(jp) + + LimitsDeserializer.fromJson(result) + } + NegMsgMessage.LABEL -> { NegMsgMessage( subId = jp.nextTextValue(), diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index 9048fe774c..b7a144bb15 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -80,6 +80,47 @@ class MessageSerializer : StdSerializer(Message::class.java) { gen.writeString(msg.subId) } + is LimitsMessage -> { + // NIP-22 wire format: ["LIMITS", { }]. Only + // the fields the relay set are emitted; absent limits stay absent. + gen.writeStartObject() + msg.canWrite?.let { gen.writeBooleanField("can_write", it) } + msg.canRead?.let { gen.writeBooleanField("can_read", it) } + msg.authForRead?.let { gen.writeBooleanField("auth_for_read", it) } + msg.authForWrite?.let { gen.writeBooleanField("auth_for_write", it) } + msg.acceptedEventKinds?.let { + gen.writeArrayFieldStart("accepted_event_kinds") + it.forEach { kind -> gen.writeNumber(kind) } + gen.writeEndArray() + } + msg.blockedEventKinds?.let { + gen.writeArrayFieldStart("blocked_event_kinds") + it.forEach { kind -> gen.writeNumber(kind) } + gen.writeEndArray() + } + msg.minPowDifficulty?.let { gen.writeNumberField("min_pow_difficulty", it) } + msg.maxMessageLength?.let { gen.writeNumberField("max_message_length", it) } + msg.maxSubscriptions?.let { gen.writeNumberField("max_subscriptions", it) } + msg.maxFilters?.let { gen.writeNumberField("max_filters", it) } + msg.maxLimit?.let { gen.writeNumberField("max_limit", it) } + msg.maxEventTags?.let { gen.writeNumberField("max_event_tags", it) } + msg.maxContentLength?.let { gen.writeNumberField("max_content_length", it) } + msg.createdAtMsecsAgo?.let { gen.writeNumberField("created_at_msecs_ago", it) } + msg.createdAtMsecsAhead?.let { gen.writeNumberField("created_at_msecs_ahead", it) } + msg.filterRateLimit?.let { gen.writeNumberField("filter_rate_limit", it) } + msg.publishingRateLimit?.let { gen.writeNumberField("publishing_rate_limit", it) } + msg.requiredTags?.let { + gen.writeArrayFieldStart("required_tags") + it.forEach { tag -> + gen.writeStartArray() + tag.forEach { part -> gen.writeString(part) } + gen.writeEndArray() + } + gen.writeEndArray() + } + gen.writeEndObject() + } + is NegMsgMessage -> { gen.writeString(msg.subId) gen.writeString(msg.message) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt index 959b4de530..22c00ec147 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -498,6 +499,76 @@ class KotlinSerializationMapperTest { assertEquals(jacksonJson, kotlinJson) } + @Test + fun serializeLimitsMessage_matchesJackson() { + val msg = + LimitsMessage( + canWrite = true, + canRead = true, + authForRead = true, + authForWrite = false, + acceptedEventKinds = listOf(0, 1, 3), + blockedEventKinds = listOf(4), + minPowDifficulty = 16, + maxMessageLength = 262144, + maxSubscriptions = 10, + maxFilters = 10, + maxLimit = 200, + maxEventTags = 2000, + maxContentLength = 131072, + createdAtMsecsAgo = 3600000L, + createdAtMsecsAhead = 60000L, + filterRateLimit = 100L, + publishingRateLimit = 500L, + requiredTags = listOf(listOf("t", "nostr"), listOf("p")), + ) + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeLimitsMessage() { + val json = + """["LIMITS",{"can_read":true,"can_write":true,"auth_for_read":true,"auth_for_write":false,""" + + """"max_subscriptions":10,"max_limit":200}]""" + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + assertTrue(deserialized is LimitsMessage) + assertEquals(true, deserialized.canRead) + assertEquals(true, deserialized.canWrite) + assertEquals(true, deserialized.authForRead) + assertEquals(false, deserialized.authForWrite) + assertEquals(10, deserialized.maxSubscriptions) + assertEquals(200, deserialized.maxLimit) + assertNull(deserialized.maxFilters) + } + + @Test + fun crossDeserializationLimitsMessage() { + val msg = + LimitsMessage( + canRead = true, + canWrite = false, + maxLimit = 500, + acceptedEventKinds = listOf(1, 30023), + requiredTags = listOf(listOf("t", "nostr")), + ) + + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson) + assertTrue(kotlinDeserialized is LimitsMessage) + assertEquals(msg.maxLimit, kotlinDeserialized.maxLimit) + assertEquals(msg.acceptedEventKinds, kotlinDeserialized.acceptedEventKinds) + assertEquals(msg.requiredTags, kotlinDeserialized.requiredTags) + + val kotlinJson = KotlinSerializationMapper.toJson(msg) + val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson) + assertTrue(jacksonDeserialized is LimitsMessage) + assertEquals(msg.maxLimit, jacksonDeserialized.maxLimit) + assertEquals(msg.acceptedEventKinds, jacksonDeserialized.acceptedEventKinds) + assertEquals(msg.requiredTags, jacksonDeserialized.requiredTags) + } + @Test fun crossDeserializationMessages() { val messages = From 9daa0b3e48f3447c160df1550a4d46ab5fa76e63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 15:39:04 +0000 Subject: [PATCH 37/45] feat: cache and expose relay LIMITS per relay Adds RelayLimits, a passive connection-listener accessory (modeled on RelayAuthenticator) that caches the latest LIMITS each relay advertises and publishes it as a Compose-stable StateFlow, so consumers can read a relay's current rights/limits instead of only observing the raw message. - RelayLimits: caches LimitsMessage per NormalizedRelayUrl, exposes limitsFlow (StateFlow), get(url) and snapshot(); connection-scoped (entry dropped on disconnect so stale limits don't leak). - LimitsMessage marked @Immutable for Compose stability in the flow map. - Wired into AppModules next to relayStats (Amethyst.instance.relayLimits). - Tests: per-relay caching, later-replaces-earlier, independent relays, drop-on-disconnect, and ignore non-LIMITS messages. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 + .../relay/client/limits/RelayLimits.kt | 100 +++++++++++++ .../relay/commands/toClient/LimitsMessage.kt | 5 +- .../relay/client/limits/RelayLimitsTest.kt | 136 ++++++++++++++++++ 4 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 393e9b64d8..5cbb2cc366 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -122,6 +122,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker +import com.vitorpamplona.quartz.nip01Core.relay.client.limits.RelayLimits import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder @@ -710,6 +711,9 @@ class AppModules( // Captures statistics about relays val relayStats = RelayStats(client) + // Caches the latest LIMITS (rights + limits) each relay advertises. + val relayLimits = RelayLimits(client) + // Resource-usage ledger: relay traffic/reconnect + connection-time, // foreground-time, process-CPU, and signature-verification collectors. init { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt new file mode 100644 index 0000000000..36acfaf475 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.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.quartz.nip01Core.relay.client.limits + +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.toClient.LimitsMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Caches the latest `LIMITS` (relay rights + limits) advertised by each relay. + * + * A relay sends `LIMITS` on connect and again whenever the connection's rights + * change (e.g. after a successful NIP-42 AUTH flips `can_write` on). Like + * [com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator], + * this is an accessory that registers a [RelayConnectionListener], keeps + * per-relay state, and publishes it as a Compose-stable [StateFlow] — but it is + * purely passive: it never replies to the relay. + * + * The cache is connection-scoped: a relay's entry is dropped on disconnect, so a + * stale limit from a previous session never leaks into a new one. A fresh + * connection re-advertises `LIMITS` on connect. + */ +class RelayLimits( + val client: INostrClient, +) { + // onIncomingMessage / onDisconnected fire on the per-relay socket dispatcher + // thread, so this is mutated concurrently. MutableStateFlow.update is an + // atomic compare-and-set loop over an immutable PersistentMap, so concurrent + // writers from different relays never corrupt the map or lose an update. + private val _limitsFlow = MutableStateFlow>(persistentMapOf()) + + /** + * Per-relay `LIMITS` as an immutable, Compose-stable snapshot map. The map + * identity changes on every mutation, so downstream + * [kotlinx.coroutines.flow.distinctUntilChanged] and Compose `@Immutable` + * skipping both work correctly. + */ + val limitsFlow: StateFlow> = _limitsFlow.asStateFlow() + + /** The most recent `LIMITS` the relay advertised, or null if none seen on the current connection. */ + fun get(url: NormalizedRelayUrl): LimitsMessage? = _limitsFlow.value[url] + + fun snapshot(): Map = _limitsFlow.value + + private val clientListener = + object : RelayConnectionListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is LimitsMessage) { + _limitsFlow.update { it.putting(relay.url, msg) } + } + } + + override fun onDisconnected(relay: IRelayClient) { + _limitsFlow.update { it.removing(relay.url) } + } + } + + init { + Log.d("RelayLimits", "Init, Subscribe") + client.addConnectionListener(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("RelayLimits", "Destroy, Unsubscribe") + client.removeConnectionListener(clientListener) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt index cb2f68f2f6..38b692825b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient +import androidx.compose.runtime.Immutable + /** - * NIP-22 `LIMITS` message: the relay advertises the current rights and limits + * `LIMITS` message: the relay advertises the current rights and limits * for this connection. Sent upon connection and again at any point during the * connection to reflect the client's changing rights (e.g. after a NIP-42 AUTH). * @@ -32,6 +34,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient * any previously cached value and not assume a default. Clients cache this * payload on the relay connection and apply it when sending `EVENT`s and `REQ`s. */ +@Immutable class LimitsMessage( /** Whether clients may publish events to this relay. */ val canWrite: Boolean? = null, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt new file mode 100644 index 0000000000..b88f2e2dfc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt @@ -0,0 +1,136 @@ +/* + * 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.limits + +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.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RelayLimitsTest { + private class CapturingClient( + private val delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + 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() = true + + override fun sendOrConnectAndSync(cmd: Command) = Unit + + override fun sendIfConnected(cmd: Command) = Unit + + override fun disconnect() = Unit + } + + private fun setup(): Pair { + val client = CapturingClient() + val limits = RelayLimits(client) + val listener = client.captured ?: error("RelayLimits did not register a listener") + return limits to listener + } + + @Test + fun cachesLimitsPerRelay() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + assertNull(limits.get(relay.url), "No limits before any LIMITS message") + + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = true, maxLimit = 200)) + + assertEquals(true, limits.get(relay.url)?.canWrite) + assertEquals(200, limits.get(relay.url)?.maxLimit) + assertEquals(limits.get(relay.url), limits.limitsFlow.value[relay.url]) + } + + @Test + fun laterLimitsReplaceEarlierOnes() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = false, maxLimit = 200)) + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = true, maxLimit = 500)) + + // A relay re-advertises LIMITS when rights change (e.g. after AUTH flips can_write). + assertEquals(true, limits.get(relay.url)?.canWrite) + assertEquals(500, limits.get(relay.url)?.maxLimit) + } + + @Test + fun tracksLimitsForDistinctRelaysIndependently() { + val (limits, listener) = setup() + val relayA = FakeRelayClient(NormalizedRelayUrl("wss://a.example/")) + val relayB = FakeRelayClient(NormalizedRelayUrl("wss://b.example/")) + + listener.onIncomingMessage(relayA, "", LimitsMessage(maxLimit = 100)) + listener.onIncomingMessage(relayB, "", LimitsMessage(maxLimit = 999)) + + assertEquals(100, limits.get(relayA.url)?.maxLimit) + assertEquals(999, limits.get(relayB.url)?.maxLimit) + assertEquals(2, limits.snapshot().size) + } + + @Test + fun dropsCachedLimitsOnDisconnect() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onIncomingMessage(relay, "", LimitsMessage(canRead = true)) + assertTrue(limits.get(relay.url) != null) + + listener.onDisconnected(relay) + assertNull(limits.get(relay.url), "Limits are connection-scoped and cleared on disconnect") + assertTrue(limits.snapshot().isEmpty()) + } + + @Test + fun ignoresNonLimitsMessages() { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onIncomingMessage(relay, "", EoseMessage("sub1")) + + assertNull(limits.get(relay.url)) + assertTrue(limits.snapshot().isEmpty()) + } +} From 041abf9e37b8354d43408cbdb2720a1e7daeaeaa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 17:21:00 +0000 Subject: [PATCH 38/45] refactor: reconcile duplicate LIMITS code and rename client accessory Cleanup after adding LimitsMessage + the client-side cache: - Remove the unused experimental LIMITS prototype (experimental/limits/Limits.kt + LimitProcessor.kt). Its @Serializable model duplicated LimitsMessage (minus auth_for_read/auth_for_write and the Message wiring); the processor's clamp/reject logic is superseded by the server-side LimitsPolicy and will be reincarnated as pure helpers on LimitsMessage. Both were prototypes with no references (preserved in git history). - Rename the client accessory RelayLimits -> RelayLimitsTracker so it no longer collides on simple name with the relay-server-side nip01Core.relay.server.policies.RelayLimits (the operator-configured limits a relay enforces and advertises). Updates AppModules and the test. No behavior change. quartz:jvmTest (RelayLimitsTrackerTest 5/5) and amethyst play-flavor compile are green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 +- .../experimental/limits/LimitProcessor.kt | 113 ------------------ .../quartz/experimental/limits/Limits.kt | 44 ------- .../{RelayLimits.kt => RelayLimitsTracker.kt} | 10 +- ...imitsTest.kt => RelayLimitsTrackerTest.kt} | 8 +- 5 files changed, 13 insertions(+), 166 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/Limits.kt rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/{RelayLimits.kt => RelayLimitsTracker.kt} (92%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/{RelayLimitsTest.kt => RelayLimitsTrackerTest.kt} (95%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 5cbb2cc366..92a2454665 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -122,7 +122,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker -import com.vitorpamplona.quartz.nip01Core.relay.client.limits.RelayLimits +import com.vitorpamplona.quartz.nip01Core.relay.client.limits.RelayLimitsTracker import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder @@ -712,7 +712,7 @@ class AppModules( val relayStats = RelayStats(client) // Caches the latest LIMITS (rights + limits) each relay advertises. - val relayLimits = RelayLimits(client) + val relayLimits = RelayLimitsTracker(client) // Resource-usage ledger: relay traffic/reconnect + connection-time, // foreground-time, process-CPU, and signature-verification collectors. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt deleted file mode 100644 index 3bd5129456..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/LimitProcessor.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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.experimental.limits - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip13Pow.pow -import com.vitorpamplona.quartz.utils.TimeUtils - -class LimitProcessor { - fun wrapFilterToLimits( - filter: Filter, - sendingStr: String, - limits: Limits, - ): Filter? { - var newFilter: Filter? = filter - - if (limits.canRead != null && !limits.canRead) { - newFilter = null - } - - if (limits.maxLimit != null && filter.limit != null && filter.limit > limits.maxLimit) { - newFilter = filter.copy(limit = limits.maxLimit) - } - - if (!limits.acceptedEventKinds.isNullOrEmpty() && !filter.kinds.isNullOrEmpty()) { - val intersect = filter.kinds.filter { it in limits.acceptedEventKinds } - if (intersect.isNotEmpty()) { - newFilter = filter.copy(kinds = intersect) - } else { - newFilter = null - } - } - - if (!limits.blockedEventKinds.isNullOrEmpty() && !filter.kinds.isNullOrEmpty()) { - val intersect = filter.kinds.filter { it !in limits.blockedEventKinds } - if (intersect.isNotEmpty()) { - newFilter = filter.copy(kinds = intersect) - } else { - newFilter = null - } - } - - if (limits.maxMessageLength != null && sendingStr.length > limits.maxMessageLength) { - // TODO: figure out how to dynamically reduce filter size - newFilter = null - } - - return newFilter - } - - fun canSendEvent( - ev: Event, - sendingStr: String, - limits: Limits, - ): Boolean { - if (limits.canWrite != null && !limits.canWrite) return false - - if (!limits.acceptedEventKinds.isNullOrEmpty() && ev.kind !in limits.acceptedEventKinds) return false - if (!limits.blockedEventKinds.isNullOrEmpty() && ev.kind in limits.blockedEventKinds) return false - - if (limits.minPoW != null && ev.pow() < limits.minPoW) return false - if (limits.maxEventTags != null && ev.tags.size > limits.maxEventTags) return false - if (limits.maxContentLength != null && ev.content.length > limits.maxContentLength) return false - - if (limits.createdAtMillisecsAgo != null && ev.createdAt < TimeUtils.now() - limits.createdAtMillisecsAgo) return false - if (limits.createdAtMillisecsAhead != null && ev.createdAt > TimeUtils.now() + limits.createdAtMillisecsAhead) return false - - if (limits.requiredTags != null && !matchAll(ev, limits.requiredTags)) return false - - if (limits.maxMessageLength != null && sendingStr.length > limits.maxMessageLength) return false - - return true - } - - private fun matchAll( - ev: Event, - requiredTags: Array>, - ): Boolean = - requiredTags.all { requiredTag -> - if (requiredTag.isNotEmpty()) { - if (requiredTag.getOrNull(1) == null) { - ev.tags.any { eventTag -> - eventTag.getOrNull(0) == requiredTag[0] - } - } else { - ev.tags.any { eventTag -> - eventTag.getOrNull(0) == requiredTag[0] && eventTag.getOrNull(1) == requiredTag[1] - } - } - } else { - true - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/Limits.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/Limits.kt deleted file mode 100644 index 2526e377f9..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/limits/Limits.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.experimental.limits - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -class Limits( - @SerialName("can_write") val canWrite: Boolean?, - @SerialName("can_read") val canRead: Boolean?, - @SerialName("accepted_event_kinds") val acceptedEventKinds: Set?, - @SerialName("blocked_event_kinds") val blockedEventKinds: Set?, - @SerialName("min_pow_difficulty") val minPoW: Int?, - @SerialName("max_message_length") val maxMessageLength: Int?, - @SerialName("max_subscriptions") val maxSubscriptions: Int?, - @SerialName("max_filters") val maxFilters: Int?, - @SerialName("max_limit") val maxLimit: Int?, - @SerialName("max_event_tags") val maxEventTags: Int?, - @SerialName("max_content_length") val maxContentLength: Int?, - @SerialName("created_at_msecs_ago") val createdAtMillisecsAgo: Long?, - @SerialName("created_at_msecs_ahead") val createdAtMillisecsAhead: Long?, - @SerialName("filter_rate_limit") val filterRateLimit: Long?, - @SerialName("publishing_rate_limit") val publishingRateLimit: Long?, - @SerialName("required_tags") val requiredTags: Array>?, -) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt similarity index 92% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt index 36acfaf475..a8ffa37d63 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimits.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt @@ -47,8 +47,12 @@ import kotlinx.coroutines.flow.update * The cache is connection-scoped: a relay's entry is dropped on disconnect, so a * stale limit from a previous session never leaks into a new one. A fresh * connection re-advertises `LIMITS` on connect. + * + * Named `…Tracker` to avoid colliding with the relay-*server* side's + * [com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits], which + * is the operator-configured source of truth a relay enforces and advertises. */ -class RelayLimits( +class RelayLimitsTracker( val client: INostrClient, ) { // onIncomingMessage / onDisconnected fire on the per-relay socket dispatcher @@ -88,13 +92,13 @@ class RelayLimits( } init { - Log.d("RelayLimits", "Init, Subscribe") + Log.d("RelayLimitsTracker", "Init, Subscribe") client.addConnectionListener(clientListener) } fun destroy() { // makes sure to run - Log.d("RelayLimits", "Destroy, Unsubscribe") + Log.d("RelayLimitsTracker", "Destroy, Unsubscribe") client.removeConnectionListener(clientListener) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt similarity index 95% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt index b88f2e2dfc..6547aeb9bb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt @@ -33,7 +33,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue -class RelayLimitsTest { +class RelayLimitsTrackerTest { private class CapturingClient( private val delegate: INostrClient = EmptyNostrClient(), ) : INostrClient by delegate { @@ -62,10 +62,10 @@ class RelayLimitsTest { override fun disconnect() = Unit } - private fun setup(): Pair { + private fun setup(): Pair { val client = CapturingClient() - val limits = RelayLimits(client) - val listener = client.captured ?: error("RelayLimits did not register a listener") + val limits = RelayLimitsTracker(client) + val listener = client.captured ?: error("RelayLimitsTracker did not register a listener") return limits to listener } From 9b7aca77a6b3d6eb3e40def2df710dd45c698e6e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:28:27 +0000 Subject: [PATCH 39/45] docs: add relay LIMITS status + roadmap plan Captures what's done (parse, RelayLimitsTracker, cleanup), the audit fixes to fold in (kotlinx parse leniency to match Jackson on iOS, drop stale NIP-22 labels, make LimitsMessage a data class), and the remaining roadmap (client apply helpers, server-side emit, NIP-11 bridge, amy tooling). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- quartz/plans/2026-07-16-relay-limits.md | 113 ++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 quartz/plans/2026-07-16-relay-limits.md diff --git a/quartz/plans/2026-07-16-relay-limits.md b/quartz/plans/2026-07-16-relay-limits.md new file mode 100644 index 0000000000..b0426776b2 --- /dev/null +++ b/quartz/plans/2026-07-16-relay-limits.md @@ -0,0 +1,113 @@ +# Relay `LIMITS` message — status & roadmap + +Support for the relay-to-client `LIMITS` frame (from +[nostr-protocol/nips#1434](https://github.com/nostr-protocol/nips/pull/1434)), +where a relay advertises the current connection's rights and limits — on +connect and again whenever they change (e.g. after a NIP-42 AUTH flips +`can_write`). Live-verified against `wss://pipe.imwald.eu/` (which sends `AUTH` +then `LIMITS`). + +Note: `LIMITS` is **not** NIP-22 (that's Comment / kind 1111); it's the #1434 +proposal and is label-based on the wire, so the code keys off the `"LIMITS"` +label rather than a NIP number. + +## Done (branch `claude/limits-message-support-g5plrd`, PR #3597) + +- **Parse** — `LimitsMessage` model (`nip01Core/relay/commands/toClient/`) with + every #1434 field plus the two non-spec extensions this relay sends + (`auth_for_read` / `auth_for_write`). Wired into both codecs: + Jackson (`LimitsDeserializer` + `MessageSerializer`, jvmAndroid) and + kotlinx (`LimitsKSerializer` + `MessageKSerializer`, iOS/native). Unknown + fields are ignored, so a future field can't re-trigger the original + "Message LIMITS is not supported" crash. +- **Cache/expose** — `RelayLimitsTracker` (`nip01Core/relay/client/limits/`), + a passive `RelayConnectionListener` accessory (modeled on + `RelayAuthenticator`) that caches the latest `LimitsMessage` per + `NormalizedRelayUrl` and publishes a Compose-stable + `StateFlow>`. Connection-scoped (dropped on + disconnect). Wired in `AppModules` as `Amethyst.instance.relayLimits`. +- **Cleanup** — removed the unused experimental prototype + (`experimental/limits/Limits.kt` + `LimitProcessor.kt`); renamed the client + accessory to `…Tracker` to avoid colliding with the server-side + `relay/server/policies/RelayLimits`. + +## Audit fixes to fold in (small, do first) + +From the 2026-07-16 review of the branch: + +1. **kotlinx parse should be as lenient as Jackson.** `MessageKSerializer` is + the *iOS* incoming path; today `LimitsKSerializer.deserializeFromElement` + throws on a mistyped field (`(tag as JsonArray)`, `.jsonPrimitive.int`) or a + payload-less `["LIMITS"]` (`array[1].jsonObject`), where Jackson coerces. + Same frame → parsed on Android, dropped on iOS. Use `intOrNull` / + `booleanOrNull` / `longOrNull`, guard the array cast, and default a missing + payload object to an empty `LimitsMessage`. +2. **Drop the stale "NIP-22" labels** in `LimitsKSerializer` KDoc and the + `// NIP-22 wire format` comments in `MessageKSerializer` / `MessageSerializer` + (already removed from `LimitsMessage.kt`). +3. **Make `LimitsMessage` a `data class`** so `StateFlow.distinctUntilChanged` + suppresses no-op emissions when a relay re-advertises identical limits, and + tests get value equality. +4. Fold the explicit-`null`→`false`/`0` coercion nuance into (1) via the + `…OrNull` accessors on both sides. + +## Roadmap + +### 1. Client-side apply/enforcement (highest user-facing value) + +#1434 says clients MUST apply limits when sending. Provide **pure, testable +helpers on `LimitsMessage`** (mirror the server's `LimitsPolicy` logic; the +deleted `LimitProcessor` in git history is a starting sketch): + +- `clamp(filter)` / `clamp(filters)` → cap each filter `limit` to `max_limit`. +- `rejectionForPublish(event): String?` → `can_write`, `accepted`/`blocked + _event_kinds`, `max_content_length`, `max_event_tags`, `min_pow_difficulty`, + `created_at_msecs_ago`/`ahead` window, `required_tags`. +- `canRead()` / `canWrite()` convenience. + +Keep the helpers in quartz (pure, no side effects). Wire them into the send +path at the app layer (subscription/filter-assembly + publish), reading from +`RelayLimitsTracker`. Decide the UX for a rejected publish (surface vs +silently drop) with the maintainer — this is the one opinionated call. + +### 2. Server-side emit (`geode`) — the symmetric half + +The relay side already **enforces** (`LimitsPolicy` + `RelaySession`) and +**advertises via NIP-11** (`RelayLimits.toNip11Limitation()`), but never sends +the dynamic `LIMITS` frame. Add: + +- `RelayLimits.toLimitsMessage(canRead, canWrite, authForRead, authForWrite)` + next to `toNip11Limitation()`, so one source of truth drives NIP-11 *and* the + frame. +- `RelaySession` / `EventSourceServer` sends `LIMITS` on connect and **re-sends + after AUTH** when effective rights change. +- Then geode ↔ our own client becomes interop-testable (relayBench / the amy + serve path). + +### 3. NIP-11 ↔ LIMITS bridge + +The two overlap ~80% (`RelayInformationLimitation` vs `LimitsMessage`). Add +`LimitsMessage.toNip11Limitation()` / `RelayInformationLimitation +.toLimitsMessage()`, and let `RelayLimitsTracker` **seed** from the NIP-11 doc +before the socket connects, then override with the live frame — one "limits of +relay X" model regardless of source. + +### 4. Tooling, tests, docs + +- `amy relay info` already prints the NIP-11 doc; add the live `LIMITS` frame + (the interop tool the repo already leans on). +- Catalog `RelayLimitsTracker` in + `nip01Core/relay/client/accessories/README.md`. +- Test the **AUTH → re-LIMITS** transition (`auth_for_write` flipping after a + successful AUTH) — the one dynamic behavior not yet exercised live. + +### 5. Spec follow-up + +`auth_for_read` / `auth_for_write` are not in #1434. Worth a note on the PR; +we tolerate them as extras either way (they pair with the `AUTH` this relay +also sends). + +## Suggested sequencing + +audit fixes → (1) client apply helpers → (2) server emit → (3) NIP-11 bridge → +(4) tooling. From ae81c93c04f5966a46e074237b6b385c024e32cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 03:04:27 +0000 Subject: [PATCH 40/45] feat: global background-notification-service master + per-account participation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigns the always-on notification control into two switches: - A global master ("Background notification service") — a persisted, battery-saver "airplane mode" stored in global preferences that overrides every account. The Quick Settings tile toggles this; it survives restarts and crashes until re-enabled. - Per-account participation ("Keep this account active in the background") — the existing per-account setting, now surfaced as a per-write-account list under the master in the Notification Settings screen. The always-on service runs while the master is on AND at least one writable account participates; turning the master off tears down every layer for all accounts. The manager reacts to both the master flow and each loaded account's participation flag, and the restart layers (watchdog/boot/worker) honor the same rule. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TKLqvcC1vobUYs3PxL2zWU --- amethyst/src/main/AndroidManifest.xml | 11 ++ .../com/vitorpamplona/amethyst/AppModules.kt | 6 +- .../amethyst/LocalPreferences.kt | 37 +++++ .../AlwaysOnNotificationServiceManager.kt | 100 +++++++++---- .../notifications/NotificationRelayService.kt | 16 +- .../NotificationServiceTileService.kt | 141 ++++++++++++++++++ .../settings/NotificationSettingsScreen.kt | 76 +++++++++- amethyst/src/main/res/values/strings.xml | 9 ++ 8 files changed, 353 insertions(+), 43 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 5a7960c004..42e59a2bb1 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -384,6 +384,17 @@ android:value="Persistent real-time messaging relay connection for Nostr protocol. Maintains WebSocket connections to user-configured inbox relays for immediate notification delivery of direct messages, zaps, and mentions." /> + + + + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 393e9b64d8..fce9d8206a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -1100,11 +1100,13 @@ class AppModules( } } - // Watch for account login and start/stop always-on notification service + // Watch for account login and start/stop always-on notification service. + // The manager gates on the global master switch + each account's participation + // (not the active account), so it only needs to run while someone is logged in. applicationIOScope.launch { sessionManager.accountContent.collectLatest { state -> if (state is AccountState.LoggedIn) { - alwaysOnNotificationServiceManager.watchAccount(state.account) + alwaysOnNotificationServiceManager.start() } else { alwaysOnNotificationServiceManager.stop() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index b437a6e838..a6783c51da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -71,6 +71,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -94,6 +95,11 @@ data class AccountInfo( private object PrefKeys { const val CURRENT_ACCOUNT = "currently_logged_in_account" + + // Global (non-account) master switch for the always-on notification service. + // When off, the service is suppressed for every account regardless of each + // account's own participation flag. Persisted so it survives restarts/crashes. + const val NOTIFICATION_SERVICE_ENABLED = "notification_service_enabled" const val SAVED_ACCOUNTS = "all_saved_accounts" const val NOSTR_PRIVKEY = "nostr_privkey" const val NOSTR_PUBKEY = "nostr_pubkey" @@ -205,6 +211,37 @@ object LocalPreferences { private val savedAccountsMutex = Mutex() private val cachedAccounts: MutableMap = mutableMapOf() + // Global master switch for the always-on notification service ("Background + // notification service"). Default ON: existing users keep current behavior, and + // per-account participation decides who actually stays active. + // + // Stored in PLAIN (non-encrypted) SharedPreferences on purpose. It is a non-sensitive + // global boolean, and — unlike encryptedPreferences(), which asserts non-main — plain + // prefs can be read synchronously on ANY thread. The restart-layer gate + // (NotificationRelayService.isEnabled) is synchronous and runs in fresh processes (boot + // receiver, WorkManager), so it MUST read the persisted value without a suspend hop; + // otherwise a saved OFF would be missed on cold boot and the service would resurrect. + // The flow is lazily seeded from disk once (synchronous, main-safe) and is thereafter + // the source of truth, so there is no async hydrate that could clobber a user toggle. + private fun globalSettingsPrefs(): SharedPreferences = + Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE) + + private val notificationServiceEnabled: MutableStateFlow by lazy { + MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)) + } + + fun notificationServiceEnabledFlow(): StateFlow = notificationServiceEnabled + + fun isNotificationServiceEnabled(): Boolean = notificationServiceEnabled.value + + fun setNotificationServiceEnabled(enabled: Boolean) { + // In-memory update is the source of truth (main-safe); plain-prefs edit{} persists + // asynchronously via apply(), also main-safe. No suspend/hydrate hop, so no window + // where a late disk read can clobber this write. + notificationServiceEnabled.value = enabled + globalSettingsPrefs().edit { putBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, enabled) } + } + suspend fun currentAccount(): String? { if (currentAccount == null) { currentAccount = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt index b124ba8ca7..58c11797f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -22,14 +22,18 @@ package com.vitorpamplona.amethyst.service.notifications import android.content.Context import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch /** @@ -41,16 +45,23 @@ import kotlinx.coroutines.launch * L4 - BootCompletedReceiver (restart on boot) * L5 - ServiceWatchdogManager (AlarmManager, 5-min health check) * - * When enabled, all layers activate. When disabled, all layers deactivate. - * The manager watches the account's alwaysOnNotificationService setting - * and reacts to changes in real time. + * Two switches gate the system: * - * While enabled, every saved writable account is kept loaded in - * [AccountCacheState] so GiftWraps addressed to any of them (delivered via - * open relay subscriptions) get unwrapped by the owning account's - * `newNotesPreProcessor`. Without this, wraps for non-active accounts would - * sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber - * able to decrypt them. + * - The **global master** ([LocalPreferences.notificationServiceEnabledFlow], the + * "Background notification service" toggle / Quick Settings tile). When off, every + * layer is torn down and nothing restarts, regardless of any account's setting — + * this is the battery-saver "airplane mode". Persisted, so an explicit off survives + * restarts and crashes. + * - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService], + * "Keep this account active in the background"). While the master is on, the service + * runs as long as **at least one** writable account participates. + * + * While the master is on, every saved writable account is kept loaded in + * [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps + * addressed to any of them (delivered via open relay subscriptions) get unwrapped by + * the owning account's `newNotesPreProcessor`. Without this, wraps for non-active + * accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no + * subscriber able to decrypt them. */ class AlwaysOnNotificationServiceManager( private val context: Context, @@ -68,22 +79,52 @@ class AlwaysOnNotificationServiceManager( private var wasEnabled = false /** - * Starts watching the given account's always-on setting. - * When the setting changes, all layers are started or stopped accordingly. - * On initial load with false, nothing happens (no-op for users who never enabled it). + * Starts watching the global master switch and the participation flags of every + * loaded writable account. The service layers run while the master is on AND at + * least one account participates; the master overrides everything when off. + * + * Idempotent: safe to call again on account switch/login — it restarts the watch. */ - fun watchAccount(account: Account) { + @OptIn(ExperimentalCoroutinesApi::class) + fun start() { watchJob?.cancel() wasEnabled = false watchJob = scope.launch { - account.settings.alwaysOnNotificationService.collectLatest { enabled -> - if (enabled) { - wasEnabled = true - enableAllLayers() - } else if (wasEnabled) { - disableAllLayers() + localPreferences.notificationServiceEnabledFlow().collectLatest { masterEnabled -> + if (!masterEnabled) { + // Global airplane mode: suppress every layer regardless of + // per-account participation, and stop keeping accounts loaded. + if (wasEnabled) { + disableServiceLayers() + wasEnabled = false + } + stopMultiAccountPreload() + return@collectLatest } + + // Master on: keep every writable account loaded so its participation + // flag is observable and its gift wraps can decrypt, then run the + // service only while at least one account is participating. + startMultiAccountPreload() + accountsCache.accounts + .flatMapLatest { accounts -> + val flags = accounts.values.map { it.settings.alwaysOnNotificationService } + if (flags.isEmpty()) { + flowOf(false) + } else { + combine(flags) { values -> values.any { it } } + } + }.distinctUntilChanged() + .collectLatest { anyParticipating -> + if (anyParticipating) { + wasEnabled = true + enableServiceLayers() + } else if (wasEnabled) { + disableServiceLayers() + wasEnabled = false + } + } } } } @@ -93,10 +134,15 @@ class AlwaysOnNotificationServiceManager( watchJob = null preloadJob?.cancel() preloadJob = null + // Logout/terminate: tear the layers down explicitly. Otherwise the watchdog alarm + // and periodic worker stay scheduled and would resurrect the service for a + // logged-out user (nobody participating). + disableServiceLayers() + wasEnabled = false } - private fun enableAllLayers() { - Log.d(TAG, "Enabling all notification service layers") + private fun enableServiceLayers() { + Log.d(TAG, "Enabling notification service layers") // L1: Start foreground service NotificationRelayService.start(context) @@ -108,12 +154,10 @@ class AlwaysOnNotificationServiceManager( ServiceWatchdogManager.schedule(context) // L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest - - startMultiAccountPreload() } - private fun disableAllLayers() { - Log.d(TAG, "Disabling all notification service layers") + private fun disableServiceLayers() { + Log.d(TAG, "Disabling notification service layers") // L1: Stop foreground service NotificationRelayService.stop(context) @@ -123,8 +167,6 @@ class AlwaysOnNotificationServiceManager( // L5: Cancel watchdog alarm ServiceWatchdogManager.cancel(context) - - stopMultiAccountPreload() } /** @@ -154,7 +196,7 @@ class AlwaysOnNotificationServiceManager( /** * Cancels the preload collector and releases every cached account except the - * currently active one, so users with the setting off return to single-account + * currently active one, so users with the master off return to single-account * memory/battery footprint. */ private fun stopMultiAccountPreload() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index c255ea4b36..2f48e5466b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -37,6 +37,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.pluralStringRes @@ -117,13 +118,18 @@ class NotificationRelayService : Service() { context.stopService(Intent(context, NotificationRelayService::class.java)) } + // Gate for the restart layers (watchdog, boot, catch-up worker, onResume): the + // global master must be on, and — matching the manager's rule — at least one loaded + // writable account must be participating. An empty cache (logged out, or accounts + // not yet loaded) gates OFF, so the layers never resurrect the service for a + // logged-out user; on cold boot the account loads headlessly and the manager starts + // the service itself once a participant exists. fun isEnabled(context: Context): Boolean = try { - Amethyst.instance.sessionManager - .loggedInAccount() - ?.settings - ?.alwaysOnNotificationService - ?.value == true + LocalPreferences.isNotificationServiceEnabled() && + Amethyst.instance.accountsCache.accounts.value.values.any { + it.settings.alwaysOnNotificationService.value + } } catch (e: Exception) { false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt new file mode 100644 index 0000000000..7c9b4ea907 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt @@ -0,0 +1,141 @@ +/* + * 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.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Quick Settings tile that temporarily enables or disables the always-on background + * notification service — a battery-saver "airplane mode" — without digging through + * Settings. + * + * The tile toggles the **global master** switch ([LocalPreferences.setNotificationServiceEnabled]), + * not any per-account setting: when off, [AlwaysOnNotificationServiceManager] tears down + * every layer for all accounts; when on, each account's own "keep active in the + * background" flag decides who participates. The master is persisted, so an explicit off + * survives restarts and crashes. + * + * The switch is global, so the tile does not depend on a logged-in account. It only + * needs `Amethyst.instance` (main process) to reach [LocalPreferences]; during the brief + * cold-start window before that is built — or in the `:napplet` process, which never + * hosts this tile — it renders as unavailable and the next `onStartListening` recovers. + */ +class NotificationServiceTileService : TileService() { + companion object { + private const val TAG = "NotifServiceTile" + } + + private var scope: CoroutineScope? = null + private var watchJob: Job? = null + + /** True once `Amethyst.instance` (and therefore [LocalPreferences]) is reachable. */ + private fun preferencesReady(): Boolean = + try { + Amethyst.instance + true + } catch (e: Exception) { + false + } + + override fun onStartListening() { + super.onStartListening() + // onStartListening/onStopListening are balanced by the framework, but cancel + // any stale collector defensively so a re-entrant start can't leak a scope. + watchJob?.cancel() + scope?.cancel() + + refreshTile() + if (!preferencesReady()) return + + val newScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + scope = newScope + // Keep the tile in sync when the master is flipped elsewhere (the Settings + // toggle) while the shade is open. + watchJob = + newScope.launch { + LocalPreferences.notificationServiceEnabledFlow().collectLatest { + refreshTile() + } + } + } + + override fun onStopListening() { + watchJob?.cancel() + watchJob = null + scope?.cancel() + scope = null + super.onStopListening() + } + + override fun onClick() { + super.onClick() + if (!preferencesReady()) { + Log.w(TAG, "Preferences not ready; ignoring tile click") + refreshTile() + return + } + LocalPreferences.setNotificationServiceEnabled(!LocalPreferences.isNotificationServiceEnabled()) + refreshTile() + } + + private fun refreshTile() { + val tile = qsTile ?: return + + if (!preferencesReady()) { + tile.state = Tile.STATE_UNAVAILABLE + } else { + val enabled = LocalPreferences.isNotificationServiceEnabled() + tile.state = if (enabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + tile.subtitleCompat( + getString( + if (enabled) { + R.string.always_on_notif_tile_subtitle_on + } else { + R.string.always_on_notif_tile_subtitle_off + }, + ), + ) + } + + // Label and icon come from the manifest (), + // which is the tile's default — no need to reset them on every refresh. + tile.updateTile() + } + + private fun Tile.subtitleCompat(text: CharSequence) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + subtitle = text + } + } +} 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 d0b82a09e6..7f10209e3a 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 @@ -34,10 +34,12 @@ 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.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -50,8 +52,11 @@ 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.AccountInfo +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper import com.vitorpamplona.amethyst.service.notifications.NotificationChannels import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile @@ -59,6 +64,7 @@ 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 +import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -89,8 +95,13 @@ fun NotificationSettingsScreen( @Composable private fun DeliverySection(accountViewModel: AccountViewModel) { - val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService - .collectAsStateWithLifecycle() + // Global master switch (persisted, all accounts). produceState + runCatching keeps + // the @Preview safe when Amethyst.instance / LocalPreferences aren't available. + val master by produceState(initialValue = true) { + runCatching { + LocalPreferences.notificationServiceEnabledFlow().collect { value = it } + } + } SettingsSection(R.string.notification_settings_section_delivery) { if (hasPushNotificationProvider()) { @@ -99,18 +110,69 @@ private fun DeliverySection(accountViewModel: 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() }, + title = R.string.notification_service_master_title, + description = R.string.notification_service_master_description, + checked = master, + onCheckedChange = { LocalPreferences.setNotificationServiceEnabled(it) }, ) } - if (alwaysOn) { + if (master) { + BackgroundAccountsSection() BatteryOptimizationBanner() } } +/** + * Per-account participation list, shown under the master switch: one "keep active in the + * background" toggle per write-enabled account. Each row toggles that account's own + * [AccountSettings.alwaysOnNotificationService]; because LocalPreferences caches one + * AccountSettings per npub, the toggle reaches the same instance the always-on manager + * observes, so participation changes take effect live. + */ +@Composable +private fun BackgroundAccountsSection() { + val accounts by produceState>>(emptyList()) { + value = + runCatching { + LocalPreferences + .allSavedAccounts() + .filter { it.hasPrivKey || it.loggedInWithExternalSigner } + .mapNotNull { info -> + LocalPreferences.loadAccountConfigFromEncryptedStorage(info.npub)?.let { info to it } + } + }.getOrDefault(emptyList()) + } + + if (accounts.isEmpty()) return + + SettingsSection(R.string.notification_service_accounts_title) { + accounts.forEachIndexed { index, (info, settings) -> + if (index > 0) SettingsDivider() + AccountParticipationRow(info, settings) + } + } +} + +@Composable +private fun AccountParticipationRow( + info: AccountInfo, + settings: AccountSettings, +) { + val participates by settings.alwaysOnNotificationService.collectAsStateWithLifecycle() + SettingsControlRow( + icon = MaterialSymbols.AccountCircle, + title = info.npub.toShortDisplay(), + description = stringRes(R.string.notification_service_participation_title), + onClick = { settings.toggleAlwaysOnNotificationService() }, + ) { + Switch( + checked = participates, + onCheckedChange = { settings.toggleAlwaysOnNotificationService() }, + ) + } +} + @Composable private fun DisplaySection(accountViewModel: AccountViewModel) { val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 97004239da..513e12f364 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1702,6 +1702,15 @@ Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. + Notification service + On + Off + + Background notification service + Master switch for real-time background notifications. Turn off to save battery — like an airplane mode. When off, no account stays connected in the background, and it stays off (across restarts) until you turn it back on. + Accounts active in the background + Keep this account active in the background + Split notifications by Follows Show two notification tabs — Following (people you follow) and Everyone. The unread indicator glows only for activity from people you follow. Show Messages From 452a1c8186d8f6a601146fb715f8944e01b1e8ea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:37:35 +0000 Subject: [PATCH 41/45] fix: harden LIMITS parsing and dedup, per audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the four findings from the branch audit: - Make the kotlinx codec (LimitsKSerializer, the iOS/native incoming path) as lenient as the Jackson path: mistyped fields degrade to null, non-int array elements are skipped, and a payload-less ["LIMITS"] frame yields an empty message instead of throwing. Guard the payload access in MessageKSerializer and MessageDeserializer likewise. - Make the Jackson reads (LimitsDeserializer) type-checked so an explicit JSON null or wrong-typed value stays null ("unspecified — keep previous") instead of coercing to false/0. Both codecs now behave identically. - LimitsMessage -> data class, so StateFlow.distinctUntilChanged in RelayLimitsTracker suppresses no-op emissions when a relay re-advertises identical limits, and tests get value equality. - Drop the stale "NIP-22" labels (LIMITS is nostr-protocol/nips#1434, not NIP-22) from LimitsKSerializer, MessageKSerializer and MessageSerializer. Tests: added mistyped/null-field and payload-less coverage on both the Jackson and kotlinx paths, plus a value-equality check. quartz:jvmTest (RelayWireErgonomicsTest 11, KotlinSerializationMapperTest 57, RelayLimitsTrackerTest 5) and amethyst play compile green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- .../kotlinSerialization/LimitsKSerializer.kt | 67 +++++++++++-------- .../kotlinSerialization/MessageKSerializer.kt | 7 +- .../relay/commands/toClient/LimitsMessage.kt | 2 +- .../relay/commands/RelayWireErgonomicsTest.kt | 35 ++++++++++ .../commands/toClient/LimitsDeserializer.kt | 44 +++++++----- .../commands/toClient/MessageDeserializer.kt | 11 +-- .../commands/toClient/MessageSerializer.kt | 2 +- .../KotlinSerializationMapperTest.kt | 20 ++++++ 8 files changed, 136 insertions(+), 52 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt index d4bc50164a..85d6c5c9a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LimitsKSerializer.kt @@ -23,19 +23,35 @@ package com.vitorpamplona.quartz.nip01Core.kotlinSerialization import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.add import kotlinx.serialization.json.addJsonArray -import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray -/** kotlinx-serialization codec for the NIP-22 `LIMITS` object payload. */ +/** kotlinx-serialization codec for the `LIMITS` object payload. */ object LimitsKSerializer { + // Tolerant readers so a malformed/mistyped field degrades to null instead of + // throwing: this is the iOS/native incoming-parse path, and it must match the + // leniency of the Jackson path (LimitsDeserializer) used on jvmAndroid. + private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull + + private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + + private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull + + private fun JsonObject.intList(key: String): List? = (this[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.intOrNull } + + private fun JsonObject.tagList(key: String): List>? = + (this[key] as? JsonArray)?.map { tag -> + (tag as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList() + } + fun serializeToElement(value: LimitsMessage): JsonObject = buildJsonObject { // Only emit the fields the relay actually set; absent limits stay absent. @@ -71,26 +87,23 @@ object LimitsKSerializer { fun deserializeFromElement(jsonObject: JsonObject): LimitsMessage = LimitsMessage( - canWrite = jsonObject["can_write"]?.jsonPrimitive?.boolean, - canRead = jsonObject["can_read"]?.jsonPrimitive?.boolean, - authForRead = jsonObject["auth_for_read"]?.jsonPrimitive?.boolean, - authForWrite = jsonObject["auth_for_write"]?.jsonPrimitive?.boolean, - acceptedEventKinds = jsonObject["accepted_event_kinds"]?.jsonArray?.map { it.jsonPrimitive.int }, - blockedEventKinds = jsonObject["blocked_event_kinds"]?.jsonArray?.map { it.jsonPrimitive.int }, - minPowDifficulty = jsonObject["min_pow_difficulty"]?.jsonPrimitive?.int, - maxMessageLength = jsonObject["max_message_length"]?.jsonPrimitive?.int, - maxSubscriptions = jsonObject["max_subscriptions"]?.jsonPrimitive?.int, - maxFilters = jsonObject["max_filters"]?.jsonPrimitive?.int, - maxLimit = jsonObject["max_limit"]?.jsonPrimitive?.int, - maxEventTags = jsonObject["max_event_tags"]?.jsonPrimitive?.int, - maxContentLength = jsonObject["max_content_length"]?.jsonPrimitive?.int, - createdAtMsecsAgo = jsonObject["created_at_msecs_ago"]?.jsonPrimitive?.long, - createdAtMsecsAhead = jsonObject["created_at_msecs_ahead"]?.jsonPrimitive?.long, - filterRateLimit = jsonObject["filter_rate_limit"]?.jsonPrimitive?.long, - publishingRateLimit = jsonObject["publishing_rate_limit"]?.jsonPrimitive?.long, - requiredTags = - jsonObject["required_tags"]?.jsonArray?.map { tag -> - (tag as JsonArray).map { it.jsonPrimitive.content } - }, + canWrite = jsonObject.bool("can_write"), + canRead = jsonObject.bool("can_read"), + authForRead = jsonObject.bool("auth_for_read"), + authForWrite = jsonObject.bool("auth_for_write"), + acceptedEventKinds = jsonObject.intList("accepted_event_kinds"), + blockedEventKinds = jsonObject.intList("blocked_event_kinds"), + minPowDifficulty = jsonObject.int("min_pow_difficulty"), + maxMessageLength = jsonObject.int("max_message_length"), + maxSubscriptions = jsonObject.int("max_subscriptions"), + maxFilters = jsonObject.int("max_filters"), + maxLimit = jsonObject.int("max_limit"), + maxEventTags = jsonObject.int("max_event_tags"), + maxContentLength = jsonObject.int("max_content_length"), + createdAtMsecsAgo = jsonObject.long("created_at_msecs_ago"), + createdAtMsecsAhead = jsonObject.long("created_at_msecs_ahead"), + filterRateLimit = jsonObject.long("filter_rate_limit"), + publishingRateLimit = jsonObject.long("publishing_rate_limit"), + requiredTags = jsonObject.tagList("required_tags"), ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 0892e776e6..adba985212 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -39,6 +39,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonArray @@ -99,7 +100,7 @@ object MessageKSerializer : KSerializer { } is LimitsMessage -> { - // NIP-22 wire format: ["LIMITS", { }] + // LIMITS wire format: ["LIMITS", { }] add(LimitsKSerializer.serializeToElement(value)) } @@ -167,7 +168,9 @@ object MessageKSerializer : KSerializer { } LimitsMessage.LABEL -> { - LimitsKSerializer.deserializeFromElement(array[1].jsonObject) + // Tolerate a payload-less or malformed ["LIMITS"] frame instead of throwing. + val payload = array.getOrNull(1) as? JsonObject ?: JsonObject(emptyMap()) + LimitsKSerializer.deserializeFromElement(payload) } NegMsgMessage.LABEL -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt index 38b692825b..1ecd2f20f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsMessage.kt @@ -35,7 +35,7 @@ import androidx.compose.runtime.Immutable * payload on the relay connection and apply it when sending `EVENT`s and `REQ`s. */ @Immutable -class LimitsMessage( +data class LimitsMessage( /** Whether clients may publish events to this relay. */ val canWrite: Boolean? = null, /** Whether clients may send `REQ` commands to this relay. */ diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt index d2c4499727..3b7e726019 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt @@ -172,4 +172,39 @@ class RelayWireErgonomicsTest { assertNull(parsed.canRead) assertNull(parsed.maxLimit) } + + @Test + fun limitsMessageToleratesPayloadlessFrame() { + // A malformed ["LIMITS"] with no object must not throw; it yields an empty message. + val parsed = Message.fromJson("""["LIMITS"]""") + assertTrue(parsed is LimitsMessage) + assertNull(parsed.canRead) + assertNull(parsed.maxLimit) + } + + @Test + fun limitsMessageToleratesMistypedAndNullFields() { + // Wrong-typed and explicitly-null fields degrade to null ("unspecified"), + // never to false/0, and never throw. + val json = + """["LIMITS",{"can_write":null,"max_limit":"lots","max_filters":true,""" + + """"accepted_event_kinds":[1,"x",3],"required_tags":["oops",["t","nostr"]]}]""" + val parsed = Message.fromJson(json) + assertTrue(parsed is LimitsMessage) + assertNull(parsed.canWrite, "explicit null stays null, not false") + assertNull(parsed.maxLimit, "a string is not an int -> null, not 0") + assertNull(parsed.maxFilters, "a boolean is not an int -> null") + assertEquals(listOf(1, 3), parsed.acceptedEventKinds, "non-int array elements are skipped") + // The bare "oops" string is not a tag array -> empty; the real pair survives. + assertEquals(listOf(emptyList(), listOf("t", "nostr")), parsed.requiredTags) + } + + @Test + fun limitsMessageHasValueEquality() { + // data class equality lets StateFlow.distinctUntilChanged suppress no-op re-advertisements. + assertEquals( + LimitsMessage(canWrite = true, maxLimit = 200, acceptedEventKinds = listOf(1, 2)), + LimitsMessage(canWrite = true, maxLimit = 200, acceptedEventKinds = listOf(1, 2)), + ) + } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt index fcd8e345f4..33b6a439bd 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/LimitsDeserializer.kt @@ -24,32 +24,42 @@ import com.fasterxml.jackson.databind.JsonNode class LimitsDeserializer { companion object { - private fun JsonNode.intList(field: String): List? = get(field)?.takeIf { it.isArray }?.map { it.asInt() } + // Type-checked readers: a missing field, an explicit JSON null, or a + // wrong-typed value all yield null ("unspecified — keep the previous + // value") rather than coercing to false/0. Keeps this in step with the + // kotlinx path (LimitsKSerializer) used on iOS/native. + private fun JsonNode.bool(field: String): Boolean? = get(field)?.takeIf { it.isBoolean }?.booleanValue() + + private fun JsonNode.int(field: String): Int? = get(field)?.takeIf { it.isNumber }?.intValue() + + private fun JsonNode.long(field: String): Long? = get(field)?.takeIf { it.isNumber }?.longValue() + + private fun JsonNode.intList(field: String): List? = get(field)?.takeIf { it.isArray }?.mapNotNull { it.takeIf { n -> n.isNumber }?.intValue() } private fun JsonNode.requiredTags(field: String): List>? = get(field)?.takeIf { it.isArray }?.map { tag -> - tag.map { it.asText() } + if (tag.isArray) tag.mapNotNull { it.takeIf { n -> n.isValueNode }?.asText() } else emptyList() } fun fromJson(jsonObject: JsonNode): LimitsMessage = LimitsMessage( - canWrite = jsonObject.get("can_write")?.asBoolean(), - canRead = jsonObject.get("can_read")?.asBoolean(), - authForRead = jsonObject.get("auth_for_read")?.asBoolean(), - authForWrite = jsonObject.get("auth_for_write")?.asBoolean(), + canWrite = jsonObject.bool("can_write"), + canRead = jsonObject.bool("can_read"), + authForRead = jsonObject.bool("auth_for_read"), + authForWrite = jsonObject.bool("auth_for_write"), acceptedEventKinds = jsonObject.intList("accepted_event_kinds"), blockedEventKinds = jsonObject.intList("blocked_event_kinds"), - minPowDifficulty = jsonObject.get("min_pow_difficulty")?.asInt(), - maxMessageLength = jsonObject.get("max_message_length")?.asInt(), - maxSubscriptions = jsonObject.get("max_subscriptions")?.asInt(), - maxFilters = jsonObject.get("max_filters")?.asInt(), - maxLimit = jsonObject.get("max_limit")?.asInt(), - maxEventTags = jsonObject.get("max_event_tags")?.asInt(), - maxContentLength = jsonObject.get("max_content_length")?.asInt(), - createdAtMsecsAgo = jsonObject.get("created_at_msecs_ago")?.asLong(), - createdAtMsecsAhead = jsonObject.get("created_at_msecs_ahead")?.asLong(), - filterRateLimit = jsonObject.get("filter_rate_limit")?.asLong(), - publishingRateLimit = jsonObject.get("publishing_rate_limit")?.asLong(), + minPowDifficulty = jsonObject.int("min_pow_difficulty"), + maxMessageLength = jsonObject.int("max_message_length"), + maxSubscriptions = jsonObject.int("max_subscriptions"), + maxFilters = jsonObject.int("max_filters"), + maxLimit = jsonObject.int("max_limit"), + maxEventTags = jsonObject.int("max_event_tags"), + maxContentLength = jsonObject.int("max_content_length"), + createdAtMsecsAgo = jsonObject.long("created_at_msecs_ago"), + createdAtMsecsAhead = jsonObject.long("created_at_msecs_ahead"), + filterRateLimit = jsonObject.long("filter_rate_limit"), + publishingRateLimit = jsonObject.long("publishing_rate_limit"), requiredTags = jsonObject.requiredTags("required_tags"), ) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt index 52badf4dbe..7e45082421 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt @@ -104,10 +104,13 @@ class MessageDeserializer : StdDeserializer(Message::class.java) { } LimitsMessage.LABEL -> { - jp.nextToken() - val result: JsonNode = jp.codec.readTree(jp) - - LimitsDeserializer.fromJson(result) + // Tolerate a payload-less ["LIMITS"] frame instead of throwing. + if (jp.nextToken() == JsonToken.START_OBJECT) { + val result: JsonNode = jp.codec.readTree(jp) + LimitsDeserializer.fromJson(result) + } else { + LimitsMessage() + } } NegMsgMessage.LABEL -> { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index b7a144bb15..86274bf1e6 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -81,7 +81,7 @@ class MessageSerializer : StdSerializer(Message::class.java) { } is LimitsMessage -> { - // NIP-22 wire format: ["LIMITS", { }]. Only + // LIMITS wire format: ["LIMITS", { }]. Only // the fields the relay set are emitted; absent limits stay absent. gen.writeStartObject() msg.canWrite?.let { gen.writeBooleanField("can_write", it) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt index 22c00ec147..40e7aa5884 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt @@ -543,6 +543,26 @@ class KotlinSerializationMapperTest { assertNull(deserialized.maxFilters) } + @Test + fun deserializeLimitsMessageToleratesMistypedAndMissingPayload() { + // The kotlinx path is the iOS/native incoming parser; a mistyped field or a + // payload-less ["LIMITS"] must degrade to null / empty, matching Jackson, + // rather than throwing. + val mistyped = + KotlinSerializationMapper.fromJsonToMessage( + """["LIMITS",{"can_write":null,"max_limit":"lots","accepted_event_kinds":[1,"x",3],"required_tags":["oops",["t","nostr"]]}]""", + ) + assertTrue(mistyped is LimitsMessage) + assertNull(mistyped.canWrite) + assertNull(mistyped.maxLimit) + assertEquals(listOf(1, 3), mistyped.acceptedEventKinds) + assertEquals(listOf(emptyList(), listOf("t", "nostr")), mistyped.requiredTags) + + val payloadless = KotlinSerializationMapper.fromJsonToMessage("""["LIMITS"]""") + assertTrue(payloadless is LimitsMessage) + assertNull(payloadless.maxLimit) + } + @Test fun crossDeserializationLimitsMessage() { val msg = From ccecc5edd4ac56de781f31f81bb2b01760a70adb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:49:12 +0000 Subject: [PATCH 42/45] style: spotlessApply on LocalPreferences after merging main Merged origin/main into the branch; a formatting violation in LocalPreferences.kt (globalSettingsPrefs, added on main) was surfacing in the PR merge-ref lint check. spotlessApply collapses it to one line (whitespace only, no behavior change) so CI's lint job passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ --- .../main/java/com/vitorpamplona/amethyst/LocalPreferences.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index a6783c51da..3f17440e3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -223,8 +223,7 @@ object LocalPreferences { // otherwise a saved OFF would be missed on cold boot and the service would resurrect. // The flow is lazily seeded from disk once (synchronous, main-safe) and is thereafter // the source of truth, so there is no async hydrate that could clobber a user toggle. - private fun globalSettingsPrefs(): SharedPreferences = - Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE) + private fun globalSettingsPrefs(): SharedPreferences = Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE) private val notificationServiceEnabled: MutableStateFlow by lazy { MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)) From 5c19017ef654b85adbd0a37b64b1ae0204c5a65e Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:27:09 +0000 Subject: [PATCH 43/45] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-zh-rCN/strings.xml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 102a38a788..ec088ef1ea 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -296,7 +296,7 @@ 兑换邀请中… 无法获取此邀请。链接可能已过期或其中继不可访问。 此邀请链接无效或无法用此账户打开。 - 此邀请是用新版本的应用创建,尚无法在此处打开。 询问已更新的链接,或在更新后重试。 + 此邀请是用新版本的应用创建,尚无法在此处打开。 请求已更新的链接,或在更新后重试。 此邀请链接已被撤销,不能再使用。请求新链接。 Concord 频道 您尚未加入任何Concord频道。创建一个频道,或者打开一个邀请链接。 @@ -1898,7 +1898,7 @@ 绑定单一中继 临时聊天 不论谁在线,立马聊天 - 现在在线 + 当前在线 与当前在线的任何人进行即时聊天。 开始聊天 与当前在线的人交谈 @@ -1937,6 +1937,7 @@ 成员 设为管理员 设为协管 + 分配角色: %1$s 删除职位 从群组中删除 从此群中移除 %1$s ?在重新添加或重新邀请之前这些人将失去访问权限。 @@ -1972,6 +1973,15 @@ 比特币,nostr,艺术品 位置 (gehash) u0nd + 结构 + 把这个群置于父级下方以构建一个层次结构。 + 父群 + 顶层群 + 选择父群 + 搜索群 + 没有父级(顶层) + 此群位于顶层 + 此中继上尚没有其他群 权限 私密 只有成员可以阅读消息。 From 9eb3b7561501f995539265387b89dfdc9a15e578 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 23:33:30 +0000 Subject: [PATCH 44/45] fix(concord): clarify invite failure copy for token/decrypt mismatch concord_invite_failed_incompatible is shown for the Unreadable status, which fires both for a genuinely newer bundle format AND for the far more common case where the fragment's unlock token does not decrypt the published kind-33301 bundle (a stale link, or a coordinate re-posted under a new token). The old copy asserted "created with a newer version of the app", which is usually wrong, and suggested "try again after updating" even though this state is non-retryable. Reword to lead with the likely cause (outdated/replaced link), keep the newer-version case as a secondary possibility, and point the user at getting a fresh invite link. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0157AWJNEzybTuegstbWiQ6B --- amethyst/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 513e12f364..0adf33c233 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -310,7 +310,7 @@ Redeeming invite… Could not fetch this invite. The link may be expired or its relays unreachable. This invite link is invalid or can\'t be opened with this account. - This invite was created with a newer version of the app and can\'t be opened here yet. Ask for an updated link or try again after updating. + This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link. This invite link has been revoked and can no longer be used. Ask for a new one. Concord Channels You haven\'t joined any Concord Channels yet. Create one, or open an invite link. From a2ee1880a692f79105fee9295d7e7bf2227fed2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 23:38:14 +0000 Subject: [PATCH 45/45] fix(hooks): treat Gradle distribution download failure as infra skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-push spotless gate already skips itself (warn + exit 0) when `./gradlew spotlessApply` fails for infra reasons, so a restricted sandbox can't strand a push over formatting. But its detection regex only covered dependency-resolution/network errors, not the case where the Gradle wrapper can't download the pinned distribution itself — e.g. an egress-proxy 40x on `gradle--bin.zip`. That surfaced as a hard BLOCK on an XML-only change even though CI's spotlessCheck would pass. Add the distribution-bootstrap markers (the `gradle--bin.zip` URL, the Java `Server returned HTTP response code` download exception, and "could not install gradle") to the infra-skip regex. These strings only appear when gradlew failed before running any task, so they can't mask a real formatting/compile failure (verified: a spotlessKotlinCheck violation still blocks). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0157AWJNEzybTuegstbWiQ6B --- .claude/hooks/pre-push-spotless.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.claude/hooks/pre-push-spotless.sh b/.claude/hooks/pre-push-spotless.sh index ff989f68a6..782d769d18 100755 --- a/.claude/hooks/pre-push-spotless.sh +++ b/.claude/hooks/pre-push-spotless.sh @@ -64,9 +64,13 @@ before="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)" log="$(mktemp /tmp/spotless-gate.XXXXXX.log)" if ! ./gradlew spotlessApply >"$log" 2>&1; then # Distinguish a formatting failure (block) from Gradle being unable to RUN — - # e.g. deps can't resolve in a restricted sandbox. An infra failure must not - # strand the agent; warn and let CI's spotlessCheck be the backstop. - if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out" "$log"; then + # e.g. deps can't resolve, or the wrapper can't even download the Gradle + # distribution, in a restricted sandbox. An infra failure must not strand the + # agent; warn and let CI's spotlessCheck be the backstop. The distribution + # patterns (the `gradle--bin.zip` URL, the Java download exception, and a + # proxy 40x on that fetch) only appear when `./gradlew` failed *before* running + # any task, so they can't mask a real formatting/compile error. + if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out|server returned http response code|gradle-[0-9][0-9.]*-(bin|all)\.zip|could not install gradle" "$log"; then echo "WARN: could not run spotlessApply (Gradle infra/network failure), skipping the formatting gate." >&2 echo " CI's spotlessCheck still enforces formatting on the PR." >&2 rm -f "$log"