Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs

This commit is contained in:
Claude
2026-04-22 19:37:25 +00:00
6 changed files with 199 additions and 12 deletions
@@ -2791,15 +2791,68 @@ class Account(
suspend fun saveDMRelayList(dmRelays: List<NormalizedRelayUrl>) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays))
suspend fun saveKeyPackageRelayList(keyPackageRelays: List<NormalizedRelayUrl>) = sendLiterallyEverywhere(keyPackageRelayList.saveRelayList(keyPackageRelays))
suspend fun saveKeyPackageRelayList(keyPackageRelays: List<NormalizedRelayUrl>) {
val oldRelays = keyPackageRelayList.flow.value
val newRelays = keyPackageRelays.toSet()
sendLiterallyEverywhere(keyPackageRelayList.saveRelayList(keyPackageRelays))
if (oldRelays != newRelays) {
republishEventsTo(myKeyPackageEvents(), newRelays)
}
}
suspend fun savePrivateOutboxRelayList(relays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays))
suspend fun savePrivateOutboxRelayList(relays: List<NormalizedRelayUrl>) {
val oldRelays = privateStorageRelayList.flow.value
val newRelays = relays.toSet()
sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays))
if (oldRelays != newRelays) {
republishEventsTo(accountSettingsEvents(), newRelays)
}
}
suspend fun saveSearchRelayList(searchRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(searchRelayList.saveRelayList(searchRelays))
suspend fun saveSearchRelayList(searchRelays: List<NormalizedRelayUrl>) {
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<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(indexerRelayList.saveRelayList(trustedRelays))
suspend fun saveIndexerRelayList(trustedRelays: List<NormalizedRelayUrl>) {
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<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(broadcastRelayList.saveRelayList(trustedRelays))
suspend fun saveBroadcastRelayList(trustedRelays: List<NormalizedRelayUrl>) {
val oldRelays = broadcastRelayList.flow.value
val newRelays = trustedRelays.toSet()
sendMyPublicAndPrivateOutbox(broadcastRelayList.saveRelayList(trustedRelays))
if (oldRelays != newRelays) {
republishEventsTo(accountSettingsEvents(), newRelays)
}
}
suspend fun saveLocalRelayList(relays: List<NormalizedRelayUrl>) {
val oldRelays = localRelayList.flow.value
val newRelays = relays.toSet()
localRelayList.saveRelayList(relays) {}
if (oldRelays != newRelays) {
republishEventsTo(accountSettingsEvents(), newRelays)
}
}
suspend fun saveProxyRelayList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(proxyRelayList.saveRelayList(trustedRelays))
@@ -2813,6 +2866,53 @@ class Account(
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = 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<Event> =
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<Event> =
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<Event>,
relays: Set<NormalizedRelayUrl>,
) {
if (relays.isEmpty() || events.isEmpty()) return
events.forEach { client.publish(it, relays) }
}
suspend fun requestToVanish(
relays: List<NormalizedRelayUrl>,
reason: String,
@@ -2838,7 +2938,24 @@ class Account(
client.publish(signedEvent, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value)
}
suspend fun sendNip65RelayList(relays: List<AdvertisedRelayInfo>) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays))
suspend fun sendNip65RelayList(relays: List<AdvertisedRelayInfo>) {
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<String>) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers))
@@ -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",
@@ -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.
@@ -31,6 +31,6 @@ class LocalRelayListViewModel : BasicRelaySetupInfoModel() {
.toList()
override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) {
account.localRelayList.saveRelayList(urlList) {}
account.saveLocalRelayList(urlList)
}
}
@@ -336,6 +336,10 @@
<string name="private_conversation_notification">"&lt;Unable to decrypt private message&gt;\n\nZostałeś wspomniany w prywatnej/zaszyfrowanej rozmowie pomiędzy %1$s i %2$s."</string>
<string name="account_switch_add_account_dialog_title">Dodaj nowe konto</string>
<string name="drawer_accounts">Konta</string>
<string name="drawer_section_navigate">Nawigacja</string>
<string name="drawer_section_you">Ty</string>
<string name="drawer_section_create">Utwórz</string>
<string name="drawer_section_system">System</string>
<string name="account_switch_select_account">Wybierz Konto</string>
<string name="account_switch_add_account_btn">Dodaj konto</string>
<string name="account_switch_active_account">Konto aktywne</string>
@@ -446,6 +450,14 @@
<string name="profile_badges_empty">Nie otrzymałeś jeszcze żadnych odznak.</string>
<string name="pictures">Zdjęcia</string>
<string name="shorts">Filmiki</string>
<string name="public_chats">Czaty publiczne</string>
<string name="follow_packs">Pakiety subskrybentów</string>
<string name="live_streams">Transmisje na żywo</string>
<string name="audio_rooms">Pokoje audio</string>
<string name="audio_room_stage">Scena</string>
<string name="audio_room_audience">Publiczność</string>
<string name="audio_room_raise_hand">Podnieś rękę</string>
<string name="audio_room_lower_hand">Opuść rękę</string>
<string name="longs">Filmy wideo</string>
<string name="articles">Artykuły</string>
<string name="private_bookmarks">Prywatne Zakładki</string>
@@ -806,6 +818,16 @@
<string name="call_settings_turn_url">Adres URL serwera</string>
<string name="call_settings_turn_username">Nazwa użytkownika</string>
<string name="call_settings_turn_credential">Uwierzytelnienie</string>
<string name="always_on_notif_channel_description">Utrzymuje aktywne połączenia z transmiterami skrzynki odbiorczej, umożliwiając otrzymywanie powiadomień w czasie rzeczywistym</string>
<string name="always_on_notif_title">Powiadomienia Ametyst Aktywne</string>
<string name="always_on_notif_connected">Połączono z %1$d transmiterami odbiorczymi</string>
<string name="always_on_notif_connecting">Łączenie z transmiterami odbiorczymi\u2026</string>
<string name="always_on_notif_stop">Pauza</string>
<string name="always_on_notif_setting_title">Usługa powiadomień zawsze włączona</string>
<string name="always_on_notif_setting_description">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.</string>
<string name="battery_optimization_title">Optymalizacja baterii aktywna</string>
<string name="battery_optimization_description">Android może ograniczać podłączenia transmiterów działające w tle. Wyłącz optymalizację baterii dla aplikacji Amethyst, aby zapewnić niezawodne powiadomienia.</string>
<string name="battery_optimization_fix_now">Napraw</string>
<string name="reply_notify">Powiadom: </string>
<string name="channel_list_join_conversation">Dołącz do dyskusji</string>
<string name="channel_list_user_or_group_id">ID Użytkownika lub Grupy</string>
@@ -1341,6 +1363,10 @@
<string name="like_description">Lubię</string>
<string name="zap_description">Zap</string>
<string name="change_reaction">Zmień odzew</string>
<string name="bottom_bar_settings">Dolny pasek nawigacji</string>
<string name="bottom_bar_settings_description">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.</string>
<string name="bottom_bar_settings_available">Dostępne</string>
<string name="bottom_bar_settings_reorder">Zmień kolejność</string>
<string name="reactions_settings">Ustawienia reakcji</string>
<string name="reactions_settings_description">Skonfiguruj które przyciski reakcji będą wyświetlane, ich kolejność i czy wyświetlić liczniki.</string>
<string name="reactions_settings_enabled">Włączone</string>
@@ -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()