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 050bf1ae1..38680c8c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2791,15 +2791,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)) @@ -2813,6 +2866,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, @@ -2838,7 +2938,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/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", 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/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) } } 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 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()