From b5b01f6eda25c61e8b1bcd5b4dcdf284dc1c4804 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 18:28:41 +0000 Subject: [PATCH 1/4] fix(scaffold): keep bars visible on non-scrollable lists The DisappearingScaffold hid its top/bottom bars purely from overscroll gestures, so a feed with too few items to scroll would end up with two empty bands where the chrome used to be. Pure overscroll (consumed.y == 0) from the fully-visible state is now ignored; once the bars have started moving we know the list is scrollable and edge overscroll keeps feeding bar motion as before. --- .../ui/layouts/DisappearingBarNestedScroll.kt | 12 +++++++ .../DisappearingBarNestedScrollTest.kt | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt index acab585c4..9c426379f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt @@ -36,6 +36,10 @@ import androidx.compose.ui.unit.Velocity * - onPostScroll reads `consumed.y + available.y` (the total scroll attempt that entered * the nested-scroll chain) and updates the bar offsets. Using the sum means the bars * also respond to overscroll attempts at the list edges. + * - Pure overscroll (`consumed.y == 0`) from the fully-visible state is ignored. This + * prevents short, non-scrollable lists from hiding the bars purely on an overscroll + * gesture — which would leave blank padding at the top and bottom. Once the bars have + * started moving (list is clearly scrollable), edge overscroll keeps affecting them. * - onPostFling snaps a mid-way bar to the nearest edge, using the fling's remaining * velocity as the spring's initial velocity so the settle feels continuous. No velocity * is returned upward to avoid phantom scrolls on parent containers. @@ -54,6 +58,14 @@ class DisappearingBarNestedScroll( val totalY = consumed.y + available.y if (totalY == 0f) return Offset.Zero + // If the list did not consume any scroll and the bars are fully visible, treat + // this as a non-scrollable list and keep the bars in place. Without this, a tiny + // feed would hide its chrome purely from overscroll gestures, leaving two empty + // bands at the top and bottom where the bars used to be. + val isPureOverscroll = consumed.y == 0f + val barsFullyVisible = state.topHeightOffset == 0f && state.bottomHeightOffset == 0f + if (isPureOverscroll && barsFullyVisible) return Offset.Zero + val deltaY = if (reverseLayout) -totalY else totalY applyDelta(deltaY) // Never consume: the content scrolls freely while the bars slide along. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt index 791c54b79..aa8fd6e0a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt @@ -106,6 +106,37 @@ class DisappearingBarNestedScrollTest { assertEquals(-10f, state.bottomHeightOffset) } + @Test + fun `pure overscroll from fully-visible state leaves the bars alone`() { + // A short list that cannot scroll: the LazyColumn consumes nothing and the whole + // drag comes through as `available`. We should not hide the bars from this, or we + // end up with two empty bands where the chrome used to be. + val state = state() + val connection = nsc(state) + + connection.onPostScroll(Offset(0f, 0f), Offset(0f, -80f), NestedScrollSource.UserInput) + + assertEquals(0f, state.topHeightOffset) + assertEquals(0f, state.bottomHeightOffset) + } + + @Test + fun `pure overscroll still moves bars once they are already partially hidden`() { + // Scrollable list that reached an edge: the list stops consuming but the user keeps + // flinging. Because the bars are already mid-hide we know the list was scrolling, + // so overscroll keeps feeding the bar motion — matching the "bars ride along" + // philosophy. + val state = state(topLimit = 100f, bottomLimit = 50f) + state.topHeightOffset = -20f + state.bottomHeightOffset = -20f + val connection = nsc(state) + + connection.onPostScroll(Offset(0f, 0f), Offset(0f, -40f), NestedScrollSource.UserInput) + + assertEquals(-60f, state.topHeightOffset) + assertEquals(-50f, state.bottomHeightOffset) + } + @Test fun `canScroll returning false freezes the bars`() { val state = state() From bd2fae9ca5c9e1ebb25f5e85c83d4c844c663ef4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 18:30:24 +0000 Subject: [PATCH 2/4] fix(marmot): scope Marmot storage per-account to prevent chat leakage The three Marmot stores (MLS group state, messages, KeyPackage bundles) were all initialized with the global app filesDir, so chats and MLS state from one account were visible to any other logged-in account. Pass each store a per-account subdirectory (accounts/) derived from the signer pubkey, so paths become: files/accounts//mls_groups//{state,retained,messages} files/accounts//marmot_keypackages/state Existing in-memory state was already scoped per-Account; this closes the last remaining cross-account leak on disk. --- .../amethyst/model/accountsCache/AccountCacheState.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index baf2ee137..cbf023d8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -100,13 +100,14 @@ class AccountCacheState( val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME) + val accountDir = File(rootFilesDir(), "accounts/${signer.pubKey}").apply { mkdirs() } + val mlsStore = try { - val dir = rootFilesDir() Log.d("AccountCacheState") { - "Initializing AndroidMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${dir.absolutePath}" + "Initializing AndroidMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${accountDir.absolutePath}" } - AndroidMlsGroupStateStore(dir) + AndroidMlsGroupStateStore(accountDir) } catch (e: Exception) { Log.e( "AccountCacheState", @@ -121,7 +122,7 @@ class AccountCacheState( val marmotMessageStore = try { - AndroidMarmotMessageStore(rootFilesDir()) + AndroidMarmotMessageStore(accountDir) } catch (e: Exception) { Log.e( "AccountCacheState", @@ -133,7 +134,7 @@ class AccountCacheState( val marmotKeyPackageStore = try { - AndroidKeyPackageBundleStore(rootFilesDir()) + AndroidKeyPackageBundleStore(accountDir) } catch (e: Exception) { Log.e( "AccountCacheState", From 36eda1cdea024cf2fd615d59ef93331e7158b7e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 18:32:28 +0000 Subject: [PATCH 3/4] feat(relays): republish account settings when AllRelay lists change When the user updates their relay configuration on the AllRelay screen, the newly-selected relays often have no copy of the account's existing settings (profile, follow list, mute list, other relay lists, etc.), so the user appears to "lose" their account to anyone reading from those relays until the next time each list is edited. After saving each relay list, re-publish the relevant existing events to the newly-selected relays (only when the set actually differs): - Outbox/inbox (NIP-65) or private-storage or broadcast changes republish every known account-settings event to the new relay set. - KeyPackage relay-list changes republish all active kind:30443 KeyPackage events authored by this account. - Local relay-list changes republish account-settings events locally. - Indexer relay-list changes publish kind:0 and kind:3. - Search relay-list changes publish kind:0. Local relays now flow through a new Account.saveLocalRelayList so the republish hook lives alongside every other relay-list save. https://claude.ai/code/session_01PcvDaNyzT6Tk4D7jn75Jbm --- .../vitorpamplona/amethyst/model/Account.kt | 129 +++++++++++++++++- .../relays/local/LocalRelayListViewModel.kt | 2 +- 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d26302f4f..831057941 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2773,15 +2773,68 @@ class Account( suspend fun saveDMRelayList(dmRelays: List) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays)) - suspend fun saveKeyPackageRelayList(keyPackageRelays: List) = sendLiterallyEverywhere(keyPackageRelayList.saveRelayList(keyPackageRelays)) + suspend fun saveKeyPackageRelayList(keyPackageRelays: List) { + val oldRelays = keyPackageRelayList.flow.value + val newRelays = keyPackageRelays.toSet() + sendLiterallyEverywhere(keyPackageRelayList.saveRelayList(keyPackageRelays)) + if (oldRelays != newRelays) { + republishEventsTo(myKeyPackageEvents(), newRelays) + } + } - suspend fun savePrivateOutboxRelayList(relays: List) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays)) + suspend fun savePrivateOutboxRelayList(relays: List) { + val oldRelays = privateStorageRelayList.flow.value + val newRelays = relays.toSet() + sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays)) + if (oldRelays != newRelays) { + republishEventsTo(accountSettingsEvents(), newRelays) + } + } - suspend fun saveSearchRelayList(searchRelays: List) = sendMyPublicAndPrivateOutbox(searchRelayList.saveRelayList(searchRelays)) + suspend fun saveSearchRelayList(searchRelays: List) { + val oldRelays = searchRelayList.flowNoDefaults.value + val newRelays = searchRelays.toSet() + sendMyPublicAndPrivateOutbox(searchRelayList.saveRelayList(searchRelays)) + if (oldRelays != newRelays) { + republishEventsTo( + listOfNotNull(userMetadata.getUserMetadataEvent()), + newRelays, + ) + } + } - suspend fun saveIndexerRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(indexerRelayList.saveRelayList(trustedRelays)) + suspend fun saveIndexerRelayList(trustedRelays: List) { + val oldRelays = indexerRelayList.flowNoDefaults.value + val newRelays = trustedRelays.toSet() + sendMyPublicAndPrivateOutbox(indexerRelayList.saveRelayList(trustedRelays)) + if (oldRelays != newRelays) { + republishEventsTo( + listOfNotNull( + userMetadata.getUserMetadataEvent(), + kind3FollowList.getFollowListEvent(), + ), + newRelays, + ) + } + } - suspend fun saveBroadcastRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(broadcastRelayList.saveRelayList(trustedRelays)) + suspend fun saveBroadcastRelayList(trustedRelays: List) { + val oldRelays = broadcastRelayList.flow.value + val newRelays = trustedRelays.toSet() + sendMyPublicAndPrivateOutbox(broadcastRelayList.saveRelayList(trustedRelays)) + if (oldRelays != newRelays) { + republishEventsTo(accountSettingsEvents(), newRelays) + } + } + + suspend fun saveLocalRelayList(relays: List) { + val oldRelays = localRelayList.flow.value + val newRelays = relays.toSet() + localRelayList.saveRelayList(relays) {} + if (oldRelays != newRelays) { + republishEventsTo(accountSettingsEvents(), newRelays) + } + } suspend fun saveProxyRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(proxyRelayList.saveRelayList(trustedRelays)) @@ -2795,6 +2848,53 @@ class Account( suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) + /** + * Returns all known signed replaceable events that configure this account + * (profile, contact list, relay lists, mute list, bookmarks, etc.). Events + * that have never been created or downloaded are omitted. + */ + fun accountSettingsEvents(): List = + listOfNotNull( + userMetadata.getUserMetadataEvent(), + userMetadata.getExternalIdentitiesEvent(), + kind3FollowList.getFollowListEvent(), + nip65RelayList.getNIP65RelayList(), + dmRelayList.getDMRelayList(), + keyPackageRelayList.getKeyPackageRelayList(), + privateStorageRelayList.getPrivateOutboxRelayList(), + searchRelayList.getSearchRelayList(), + trustedRelayList.getTrustedRelayList(), + proxyRelayList.getProxyRelayList(), + broadcastRelayList.getBroadcastRelayList(), + indexerRelayList.getIndexerRelayList(), + relayFeedsList.getRelayFeedsList(), + blockedRelayList.getBlockedRelayList(), + muteList.getMuteList(), + bookmarkState.getBookmarkList(), + pinState.getPinList(), + blossomServers.getBlossomServersList(), + paymentTargetsState.getPaymentTargetsEvent(), + trustProviderList.getTrustProviderList(), + cache.getAddressableNoteIfExists(appSpecific.getAppSpecificDataAddress())?.event, + ) + + /** + * Returns all currently-known signed KeyPackage events authored by this account. + */ + fun myKeyPackageEvents(): List = + cache.addressables + .filter(KeyPackageEvent.KIND, signer.pubKey) + .mapNotNull { it.event } + + /** Publishes the given events to each of the given relays. No-op if either list is empty. */ + fun republishEventsTo( + events: List, + relays: Set, + ) { + if (relays.isEmpty() || events.isEmpty()) return + events.forEach { client.publish(it, relays) } + } + suspend fun requestToVanish( relays: List, reason: String, @@ -2820,7 +2920,24 @@ class Account( client.publish(signedEvent, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value) } - suspend fun sendNip65RelayList(relays: List) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays)) + suspend fun sendNip65RelayList(relays: List) { + val oldOutbox = nip65RelayList.outboxFlowNoDefaults.value + val oldInbox = nip65RelayList.inboxFlowNoDefaults.value + val newOutbox = + relays + .filter { it.type.isWrite() } + .map { it.relayUrl } + .toSet() + val newInbox = + relays + .filter { it.type.isRead() } + .map { it.relayUrl } + .toSet() + sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays)) + if (oldOutbox != newOutbox || oldInbox != newInbox) { + republishEventsTo(accountSettingsEvents(), newOutbox) + } + } suspend fun sendBlossomServersList(servers: List) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt index aa518d2f0..0a4df4eef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt @@ -31,6 +31,6 @@ class LocalRelayListViewModel : BasicRelaySetupInfoModel() { .toList() override suspend fun saveRelayList(urlList: List) { - account.localRelayList.saveRelayList(urlList) {} + account.saveLocalRelayList(urlList) } } From 642aa6fe1fc292843eb60ea39dc99e22a0d9a708 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 22 Apr 2026 18:44:32 +0000 Subject: [PATCH 4/4] New Crowdin translations by GitHub Action --- .../src/main/res/values-pl-rPL/strings.xml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 1753c78d6..6032853ab 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -336,6 +336,10 @@ "<Unable to decrypt private message>\n\nZostałeś wspomniany w prywatnej/zaszyfrowanej rozmowie pomiędzy %1$s i %2$s." Dodaj nowe konto Konta + Nawigacja + Ty + Utwórz + System Wybierz Konto Dodaj konto Konto aktywne @@ -446,6 +450,14 @@ Nie otrzymałeś jeszcze żadnych odznak. Zdjęcia Filmiki + Czaty publiczne + Pakiety subskrybentów + Transmisje na żywo + Pokoje audio + Scena + Publiczność + Podnieś rękę + Opuść rękę Filmy wideo Artykuły Prywatne Zakładki @@ -806,6 +818,16 @@ Adres URL serwera Nazwa użytkownika Uwierzytelnienie + Utrzymuje aktywne połączenia z transmiterami skrzynki odbiorczej, umożliwiając otrzymywanie powiadomień w czasie rzeczywistym + Powiadomienia Ametyst Aktywne + Połączono z %1$d transmiterami odbiorczymi + Łączenie z transmiterami odbiorczymi\u2026 + Pauza + Usługa powiadomień zawsze włączona + Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości. + Optymalizacja baterii aktywna + Android może ograniczać podłączenia transmiterów działające w tle. Wyłącz optymalizację baterii dla aplikacji Amethyst, aby zapewnić niezawodne powiadomienia. + Napraw Powiadom: Dołącz do dyskusji ID Użytkownika lub Grupy @@ -1341,6 +1363,10 @@ Lubię Zap Zmień odzew + Dolny pasek nawigacji + Przeciągnij, aby zmienić kolejność. Przełącz, aby dodać lub usunąć element z dolnego paska. Gdy nie ma żadnych elementów, dolny pasek jest ukryty. + Dostępne + Zmień kolejność Ustawienia reakcji Skonfiguruj które przyciski reakcji będą wyświetlane, ich kolejność i czy wyświetlić liczniki. Włączone