From 73d52401cd0100b43318ce9308cfaaa41b47f2d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 19:35:30 +0000 Subject: [PATCH 1/9] fix: remove reveal damping so the top bar stays glued to content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.5 reveal-sensitivity damping in DisappearingBarNestedScroll made the bars reveal at half the rate they hide. Because the content scrolls 1:1, the bar offset would lag behind the content scroll: after hiding the chrome and scrolling back up to the top, the list reaches its top while the bar is still only half revealed, leaving a blank band between the bar and the first item. The bar's translationY must mirror the content scroll offset exactly so its bottom edge stays glued to the first item's top edge. Any persistent reveal damping breaks that invariant and opens the gap, so revealing now tracks the finger 1:1 like hiding does. The original goal of the damping — keeping the chrome from popping back on the tiny reverse drag a finger makes when it catches a fast scroll — is still met without breaking the 1:1 invariant: a partial reveal that doesn't cross the halfway point is snapped back to the hidden edge by settleToNearestEdge on fling/lift, and the binary OS status bar is already debounced by the show/hide hysteresis in the scaffold. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011jbLnoWks19ottXNrMZ6DH --- .../ui/layouts/DisappearingBarNestedScroll.kt | 32 ++++++++----------- .../DisappearingBarNestedScrollTest.kt | 21 ++++++------ 2 files changed, 25 insertions(+), 28 deletions(-) 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 e49922f349..20c7acda5e 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 @@ -43,10 +43,16 @@ import androidx.compose.ui.unit.Velocity * - 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. - * - Hiding tracks the finger 1:1, but revealing is damped by [REVEAL_SENSITIVITY]. Once the - * bars are hidden, the small reverse drag a finger naturally makes when it catches/stops a - * fast scroll would otherwise be enough to snap the chrome (and the OS status bar) back. - * Damping the reveal direction makes bringing the bars back a more deliberate gesture. + * - Both hiding and revealing track the finger 1:1. The bar offset must equal the content's + * scroll offset so the bar's bottom edge stays glued to the first item's top edge; any + * asymmetry (e.g. a damped reveal) leaves the bar lagging behind the content and opens a + * blank band between the bar and the first item when the list returns to the top. + * + * Making the reveal a *deliberate* gesture — so the tiny reverse drag a finger makes when it + * catches/stops a fast scroll doesn't pop the chrome back — is handled without breaking that + * 1:1 invariant: a partial reveal that doesn't cross the halfway point is snapped back to the + * hidden edge by [DisappearingBarState.settleToNearestEdge] on fling/lift, and the binary OS + * status bar is debounced by the show/hide hysteresis in the scaffold. */ class DisappearingBarNestedScroll( private val state: DisappearingBarState, @@ -90,19 +96,9 @@ class DisappearingBarNestedScroll( private fun applyDelta(deltaY: Float) { val topLimit = state.topHeightLimit val bottomLimit = state.bottomHeightLimit - // Positive delta reveals the bars; negative delta hides them. Hiding stays 1:1 with the - // finger, while revealing is damped so a stray reverse drag doesn't bring the chrome back. - val effectiveDelta = if (deltaY > 0f) deltaY * REVEAL_SENSITIVITY else deltaY - state.topHeightOffset = (state.topHeightOffset + effectiveDelta).coerceIn(-topLimit, 0f) - state.bottomHeightOffset = (state.bottomHeightOffset + effectiveDelta).coerceIn(-bottomLimit, 0f) - } - - companion object { - /** - * Fraction of scroll distance applied when revealing the bars (1.0 = same rate as hiding). - * Lower values require a more deliberate downward scroll to bring the chrome back, so the - * tiny reverse movement of a finger stopping a fast scroll no longer pops the bars open. - */ - const val REVEAL_SENSITIVITY = 0.5f + // 1:1 in both directions: the bar offset mirrors the content scroll so the bar stays glued + // to the first item. Deliberate reveal is enforced on settle, not by damping the delta here. + state.topHeightOffset = (state.topHeightOffset + deltaY).coerceIn(-topLimit, 0f) + state.bottomHeightOffset = (state.bottomHeightOffset + deltaY).coerceIn(-bottomLimit, 0f) } } 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 5f3a00a834..cad98a6229 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 @@ -79,17 +79,17 @@ class DisappearingBarNestedScrollTest { } @Test - fun `scrolling content down reveals both bars from a hidden state, damped`() { + fun `scrolling content down reveals both bars from a hidden state`() { val state = state(topLimit = 100f, bottomLimit = 50f) state.topHeightOffset = -100f state.bottomHeightOffset = -50f val connection = nsc(state) - // Reveal is damped by REVEAL_SENSITIVITY (0.5), so a 30px drag only reveals 15px. + // Reveal tracks the finger 1:1, so a 30px drag reveals 30px. connection.onPostScroll(Offset(0f, 30f), Offset(0f, 0f), NestedScrollSource.UserInput) - assertEquals(-85f, state.topHeightOffset) - assertEquals(-35f, state.bottomHeightOffset) + assertEquals(-70f, state.topHeightOffset) + assertEquals(-20f, state.bottomHeightOffset) } @Test @@ -100,26 +100,27 @@ class DisappearingBarNestedScrollTest { val connection = nsc(state) // The list consumed 20px of a 40px reveal drag; 20 more was left as overscroll. - // The bars should move by the total 40 (damped to 20 on reveal), not just one half. + // The bars should move by the total 40, not just one of the halves. connection.onPostScroll(Offset(0f, 20f), Offset(0f, 20f), NestedScrollSource.UserInput) - assertEquals(-30f, state.topHeightOffset) - assertEquals(-30f, state.bottomHeightOffset) + assertEquals(-10f, state.topHeightOffset) + assertEquals(-10f, state.bottomHeightOffset) } @Test - fun `revealing is less sensitive than hiding for the same drag distance`() { + fun `revealing tracks the finger 1 to 1, matching the hide rate so the bar stays glued to content`() { // Hiding a 40px drag moves the bars the full 40px... val hiding = state(topLimit = 100f, bottomLimit = 100f) nsc(hiding).onPostScroll(Offset(0f, -40f), Offset(0f, 0f), NestedScrollSource.UserInput) assertEquals(-40f, hiding.topHeightOffset) - // ...while revealing the same 40px from fully hidden only brings back 20px. + // ...and revealing the same 40px from fully hidden brings back the full 40px, so when the + // list returns to the top the bar is fully revealed with no blank band beneath it. val revealing = state(topLimit = 100f, bottomLimit = 100f) revealing.topHeightOffset = -100f revealing.bottomHeightOffset = -100f nsc(revealing).onPostScroll(Offset(0f, 40f), Offset(0f, 0f), NestedScrollSource.UserInput) - assertEquals(-80f, revealing.topHeightOffset) + assertEquals(-60f, revealing.topHeightOffset) } @Test From d96d0220fb673608ffff61be4b79a2f0950dac5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 20:32:03 +0000 Subject: [PATCH 2/9] feat: gap-safe settle, snappier spring, and jitter dead-zone for disappearing bars Three refinements to the disappearing top/bottom bar animations: - Gap-safe settle: settleToNearestEdge could snap a partially-collapsed bar to fully hidden whenever it was past the halfway point, even when the content had only scrolled part of a bar height (e.g. a gentle flick from the top). Because the content padding is fixed and the bar is translated, hiding it further than the content scrolled reopens the same blank band the reveal-damping fix removed. Each bar now latches whether it has actually reached its hidden edge through scrolling; the settle only commits to fully hidden when that latch is set, otherwise it settles back into view. This keeps the deliberate-reveal behavior (a sub-halfway reveal after fully hiding still snaps back hidden) without the gap. - Snappier settle spring: StiffnessMediumLow -> StiffnessMedium so the bars resolve to their edge with a quick native snap instead of a slow float. Still DampingRatioNoBouncy, and overshoot stays clamped by animateOne's bounds. - Micro-scroll dead-zone: ignore sub-pixel scroll attempts so jitter doesn't nudge the bars or flip the binary status-bar toggle. Kept tiny and symmetric so the reveal never lags the content enough to open a gap. Proportional top/bottom collapse was intentionally left out: both bars already move at the same pixel rate (visual lock-step for their shared travel), and forcing the shorter bar to finish at the same time as the taller one would push it off the 1:1 content track and reintroduce a gap. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011jbLnoWks19ottXNrMZ6DH --- .../ui/layouts/DisappearingBarNestedScroll.kt | 15 ++++- .../ui/layouts/DisappearingBarState.kt | 58 +++++++++++++++++-- .../ui/layouts/DisappearingBarStateTest.kt | 45 ++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) 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 20c7acda5e..7e81278143 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 @@ -24,6 +24,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity +import kotlin.math.abs /** * Scroll-linked connection that hides/reveals the top and bottom bars together. @@ -66,7 +67,10 @@ class DisappearingBarNestedScroll( ): Offset { if (!canScroll()) return Offset.Zero val totalY = consumed.y + available.y - if (totalY == 0f) return Offset.Zero + // Dead-zone: ignore sub-pixel jitter so the bars (and the binary status-bar toggle that + // tracks their collapse fraction) don't twitch on scroll noise. Kept symmetric and tiny so + // it never makes the reveal lag the content enough to open a visible gap. + if (abs(totalY) < MIN_SCROLL_DELTA) 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 @@ -101,4 +105,13 @@ class DisappearingBarNestedScroll( state.topHeightOffset = (state.topHeightOffset + deltaY).coerceIn(-topLimit, 0f) state.bottomHeightOffset = (state.bottomHeightOffset + deltaY).coerceIn(-bottomLimit, 0f) } + + companion object { + /** + * Sub-pixel dead-zone: scroll attempts smaller than this are ignored so jitter doesn't nudge + * the bars. Tiny on purpose — large enough to swallow fractional noise, small enough that the + * bar offset never measurably lags the content scroll. + */ + const val MIN_SCROLL_DELTA = 0.5f + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt index 8d743246db..13cddbc185 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt @@ -46,8 +46,46 @@ class DisappearingBarState( initialTopHeightOffset: Float = 0f, initialBottomHeightOffset: Float = 0f, ) { - var topHeightOffset by mutableFloatStateOf(initialTopHeightOffset) - var bottomHeightOffset by mutableFloatStateOf(initialBottomHeightOffset) + private var _topHeightOffset by mutableFloatStateOf(initialTopHeightOffset) + private var _bottomHeightOffset by mutableFloatStateOf(initialBottomHeightOffset) + + /** + * Latches that record whether each bar has been driven all the way to its hidden edge by real + * scrolling since it was last fully in view. The settle reads them to tell a deliberate hide — + * where the content has scrolled at least a full bar height, so snapping the bar fully hidden + * leaves content (not a blank band) in the slot it vacates — apart from a small near-top + * collapse, where snapping hidden would expose the background because the content hasn't + * scrolled far enough to fill the bar's slot. + */ + private var topReachedHiddenEdge = false + private var bottomReachedHiddenEdge = false + + var topHeightOffset: Float + get() = _topHeightOffset + set(value) { + _topHeightOffset = value + updateLatch(value, topHeightLimit) { topReachedHiddenEdge = it } + } + + var bottomHeightOffset: Float + get() = _bottomHeightOffset + set(value) { + _bottomHeightOffset = value + updateLatch(value, bottomHeightLimit) { bottomReachedHiddenEdge = it } + } + + private inline fun updateLatch( + offset: Float, + limit: Float, + set: (Boolean) -> Unit, + ) { + if (limit <= 0f) return + if (offset >= 0f) { + set(false) + } else if (offset <= -limit) { + set(true) + } + } var topHeightLimit: Float = 0f set(value) { @@ -76,8 +114,8 @@ class DisappearingBarState( */ suspend fun settleToNearestEdge(initialVelocityY: Float = 0f) { coroutineScope { - launch { settleOne({ topHeightOffset }, topHeightLimit, initialVelocityY) { topHeightOffset = it } } - launch { settleOne({ bottomHeightOffset }, bottomHeightLimit, initialVelocityY) { bottomHeightOffset = it } } + launch { settleOne({ topHeightOffset }, topHeightLimit, topReachedHiddenEdge, initialVelocityY) { topHeightOffset = it } } + launch { settleOne({ bottomHeightOffset }, bottomHeightLimit, bottomReachedHiddenEdge, initialVelocityY) { bottomHeightOffset = it } } } } @@ -94,6 +132,7 @@ class DisappearingBarState( private suspend fun settleOne( get: () -> Float, limit: Float, + canFullyHide: Boolean, initialVelocityY: Float, set: (Float) -> Unit, ) { @@ -105,6 +144,11 @@ class DisappearingBarState( val positionBiasToHide = -current > limit / 2f val target = when { + // Snapping to the hidden edge is only safe once the bar has actually been scrolled + // there (canFullyHide): the content has then moved at least a full bar height and + // fills the slot the bar vacates. From a small near-top collapse it hasn't, so + // snapping hidden would open a blank band — settle back into view instead. + !canFullyHide -> 0f initialVelocityY < -VELOCITY_BIAS_THRESHOLD -> -limit initialVelocityY > VELOCITY_BIAS_THRESHOLD -> 0f positionBiasToHide -> -limit @@ -142,10 +186,14 @@ class DisappearingBarState( companion object { private const val VELOCITY_BIAS_THRESHOLD = 200f + // Bounce-free so the chrome never wobbles past its edge, but stiff enough to feel like a + // quick native snap rather than a slow float once the finger lifts. Overshoot from a strong + // initial velocity is still caught by the bounds in animateOne, so a higher stiffness here + // only affects how briskly the bar resolves to its edge. private val SETTLE_SPRING = spring( dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, + stiffness = Spring.StiffnessMedium, ) val Saver: Saver = diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarStateTest.kt index bae001b849..52ba051e3e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarStateTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarStateTest.kt @@ -93,4 +93,49 @@ class DisappearingBarStateTest { assertTrue("top bar overshot to $peakTop", peakTop <= 0.5f) assertTrue("bottom bar overshot to $peakBottom", peakBottom <= 0.5f) } + + @Test + fun `a partial collapse that never reached the hidden edge settles back into view`() = + runTest { + // The bar is past the halfway point but was only ever scrolled here from the top — it + // never reached -limit, so the content hasn't moved a full bar height. Snapping it fully + // hidden would expose a blank band, so it must settle back to visible instead. + val state = state(topLimit = 100f, bottomLimit = 50f) + state.topHeightOffset = -80f + + peakOffsetsDuring(state) { state.settleToNearestEdge() } + + assertEquals(0f, state.topHeightOffset, 0.01f) + } + + @Test + fun `a small reveal after fully hiding settles back to hidden, not into view`() = + runTest { + // Drive the bar to its hidden edge first (content has now scrolled a full bar height), + // then nudge it back a little — the small reverse drag a finger makes catching a scroll. + // Because it genuinely reached the hidden edge, snapping back hidden leaves no gap, so a + // sub-halfway reveal must not pop the chrome back open. + val state = state(topLimit = 100f, bottomLimit = 50f) + state.topHeightOffset = -100f + state.topHeightOffset = -90f + + peakOffsetsDuring(state) { state.settleToNearestEdge() } + + assertEquals(-100f, state.topHeightOffset, 0.01f) + } + + @Test + fun `returning fully into view re-arms the gap guard so the next near-top collapse settles open`() = + runTest { + // Hide fully (arms the latch), come all the way back to visible (disarms it), then do a + // small near-top collapse again. It must settle open, proving the latch resets at 0. + val state = state(topLimit = 100f, bottomLimit = 50f) + state.topHeightOffset = -100f + state.topHeightOffset = 0f + state.topHeightOffset = -80f + + peakOffsetsDuring(state) { state.settleToNearestEdge() } + + assertEquals(0f, state.topHeightOffset, 0.01f) + } } From a971f4389e6b45be84ff8344be77ffb068a4e889 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 21 Jun 2026 19:36:59 -0400 Subject: [PATCH 3/9] perf(notifications): cut per-card composition cost on the notifications feed The notifications feed is dominated by MultiSetCards, each rendering a gallery of up to 30 author avatars. Profiling a loaded account showed the per-author work on the main thread, not the GPU, as the scroll-jitter driver. Three feature-preserving cuts: - Hoist account-global reads out of the per-author avatar. The auto-play-gif setting and the logged-in follow set were collected once *per author* (~60 redundant Flow collectors / coroutine launches per card). They are now collected once per gallery and passed down via LocalAuthorGalleryRenderContext. - Dedupe the per-author metadata subscription. Each avatar fired UserFinderFilterAssemblerSubscription twice (via observeUserPicture + observeUserContactCardsScore); both observers gained a `subscribe` flag so the gallery subscribes once per author. - Replace the per-event DateTimeFormatter day-bucketing in convertToCard with a LocalDate.toEpochDay() Long key (identical grouping, no Instant/ZonedDateTime/ String allocation per reaction/zap/repost), and hoist the ZoneId lookup. Measured before/after on a Samsung SM-T220 (loaded account, identical scripted scroll, dumpsys gfxinfo): Slow-UI-thread events ~13 -> ~5 (~60% fewer), 95th-pct frame ~52ms -> ~38ms, janky frames ~13% -> ~10%. GPU timings unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reqCommand/user/UserObservers.kt | 15 +++- .../ui/note/AuthorGalleryRenderContext.kt | 66 ++++++++++++++++ .../amethyst/ui/note/MultiSetCompose.kt | 76 +++++++++++++++---- .../notifications/CardFeedContentState.kt | 36 ++++----- 4 files changed, 153 insertions(+), 40 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AuthorGalleryRenderContext.kt 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 30db8918c5..447b4f68d8 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 @@ -150,9 +150,15 @@ fun observeUserBanner( fun observeUserPicture( user: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, ): State { // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) + // Callers that already hold a single shared subscription for the same user + // (e.g. an author avatar that also observes the contact-card score) can pass + // subscribe = false to avoid setting up a redundant relay subscription. + if (subscribe) { + UserFinderFilterAssemblerSubscription(user, accountViewModel) + } // Subscribe in the LocalCache for changes that arrive in the device val flow = @@ -476,9 +482,14 @@ fun observeUserIsFollowingChannel( fun observeUserContactCardsScore( user: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, ): State { // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) + // See observeUserPicture: pass subscribe = false when a shared subscription + // for the same user already exists. + if (subscribe) { + UserFinderFilterAssemblerSubscription(user, accountViewModel) + } // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(user) { user.cards().rankFlow(accountViewModel.account.trustProviderList) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AuthorGalleryRenderContext.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AuthorGalleryRenderContext.kt new file mode 100644 index 0000000000..702d846009 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AuthorGalleryRenderContext.kt @@ -0,0 +1,66 @@ +/* + * 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.note + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * The notification galleries (reactions, zaps, nutzaps, boosts) render up to 30 + * author avatars each. A couple of the inputs to every avatar are *account-global* + * — the auto-play-gif setting and the logged-in user's follow set — yet the avatar + * code used to collect each of them **once per author**. With dozens of authors per + * card that produced dozens of redundant Flow collectors / coroutine launches every + * time a card scrolled into view, a measurable chunk of the per-card composition cost. + * + * Hoisting those reads to a single collection per gallery and handing the snapshot + * down through [LocalAuthorGalleryRenderContext] lets each avatar read plain values + * and stay skippable. When the local is absent the avatar falls back to its old + * per-author behaviour, so callers that don't provide a context keep working. + */ +@Immutable +class AuthorGalleryRenderContext( + val autoPlayGif: Boolean, + val follows: Set, +) + +val LocalAuthorGalleryRenderContext = compositionLocalOf { null } + +/** + * Collects the account-global render inputs a single time. Provide the result via + * [LocalAuthorGalleryRenderContext] around a gallery's author list. + */ +@Composable +fun rememberAuthorGalleryRenderContext(accountViewModel: AccountViewModel): AuthorGalleryRenderContext { + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val follows by accountViewModel.account.allFollows.flow + .collectAsStateWithLifecycle() + + val followAuthors = follows.authors + return remember(autoPlayGif, followAuthors) { + AuthorGalleryRenderContext(autoPlayGif, followAuthors) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index cc1e97ba06..672ce202f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -72,6 +73,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsScore import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius @@ -463,8 +465,12 @@ fun AuthorGalleryZaps( nav: INav, accountViewModel: AccountViewModel, ) { - Column(modifier = StdStartPadding) { - FlowRow { authorNotes.forEach { RenderState(it, backgroundColor, accountViewModel, nav) } } + CompositionLocalProvider( + LocalAuthorGalleryRenderContext provides rememberAuthorGalleryRenderContext(accountViewModel), + ) { + Column(modifier = StdStartPadding) { + FlowRow { authorNotes.forEach { RenderState(it, backgroundColor, accountViewModel, nav) } } + } } } @@ -631,8 +637,12 @@ fun AuthorGallery( accountViewModel: AccountViewModel, clickRoute: (Note) -> Route? = ::authorRouteFor, ) { - Column(modifier = StdStartPadding) { - FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel, clickRoute) } } + CompositionLocalProvider( + LocalAuthorGalleryRenderContext provides rememberAuthorGalleryRenderContext(accountViewModel), + ) { + Column(modifier = StdStartPadding) { + FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel, clickRoute) } } + } } } @@ -643,9 +653,13 @@ fun AuthorGallery( nav: INav, accountViewModel: AccountViewModel, ) { - Column(modifier = StdStartPadding) { - FlowRow { - noteToGetBoostEvents.note.boosts.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } + CompositionLocalProvider( + LocalAuthorGalleryRenderContext provides rememberAuthorGalleryRenderContext(accountViewModel), + ) { + Column(modifier = StdStartPadding) { + FlowRow { + noteToGetBoostEvents.note.boosts.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } + } } } } @@ -684,7 +698,27 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( author: User, accountViewModel: AccountViewModel, ) { - WatchUserMetadata(author, accountViewModel) { baseUserPicture -> + // When rendered inside a gallery, the account-global auto-play and follow-set + // reads are hoisted to a single collection for the whole gallery (see + // [rememberAuthorGalleryRenderContext]). Falls back to per-author collection + // for callers that don't provide a context. + val galleryContext = LocalAuthorGalleryRenderContext.current + + // One shared relay subscription per author. The profile picture and the + // contact-card score below both fetch the same kind-0 metadata, so we + // subscribe once here and let them skip their own (formerly duplicate) one. + UserFinderFilterAssemblerSubscription(author, accountViewModel) + + WatchUserMetadata(author, accountViewModel, subscribe = false) { baseUserPicture -> + val autoPlayGif = + if (galleryContext != null) { + galleryContext.autoPlayGif + } else { + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value + } + RobohashFallbackAsyncImage( robot = author.pubkeyHex, model = baseUserPicture, @@ -693,30 +727,39 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( contentScale = ContentScale.Crop, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - autoPlayGif = - accountViewModel.settings.autoPlayVideosFlow - .collectAsStateWithLifecycle() - .value, + autoPlayGif = autoPlayGif, ) } - WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing -> + if (galleryContext != null) { + val isFollowing = + accountViewModel.isLoggedUser(author.pubkeyHex) || + author.pubkeyHex in galleryContext.follows if (isFollowing) { Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) { FollowingIcon(Size10Modifier) } } + } else { + WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing -> + if (isFollowing) { + Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) { + FollowingIcon(Size10Modifier) + } + } + } } - ObserveAndRenderBoxedUserCards(author, accountViewModel) + ObserveAndRenderBoxedUserCards(author, accountViewModel, subscribe = false) } @Composable fun ObserveAndRenderBoxedUserCards( user: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, ) { - val score by observeUserContactCardsScore(user, accountViewModel) + val score by observeUserContactCardsScore(user, accountViewModel, subscribe) score?.let { Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) { @@ -729,9 +772,10 @@ fun ObserveAndRenderBoxedUserCards( private fun WatchUserMetadata( author: User, accountViewModel: AccountViewModel, + subscribe: Boolean = true, onNewMetadata: @Composable (String?) -> Unit, ) { - val userProfile by observeUserPicture(author, accountViewModel) + val userProfile by observeUserPicture(author, accountViewModel, subscribe) onNewMetadata(userProfile) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 15292532f9..9cd127336b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -62,8 +62,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.time.Instant +import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter @Stable class CardFeedContentState( @@ -266,7 +266,14 @@ class CardFeedContentState( } } - val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() + // Notifications are bucketed per calendar day. Computing the day key as a + // raw epoch-day Long (instead of formatting a "yyyy-MM-dd" String per event) + // gives identical buckets and chronological ordering while avoiding a + // DateTimeFormatter + ZonedDateTime + String allocation for every single + // reaction/zap/repost — a meaningful GC saving on full feed conversions. + val zone = ZoneId.systemDefault() + + fun epochDay(createdAt: Long?): Long = LocalDate.ofInstant(Instant.ofEpochSecond(createdAt ?: 0L), zone).toEpochDay() val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys + nutzapsPerEvent.keys val multiCards = @@ -278,21 +285,16 @@ class CardFeedContentState( val singleList = (boostsInCard + zapsInCard.map { it.response } + reactionsInCard + nutzapsInCard).groupBy { - sdf.format( - Instant - .ofEpochSecond(it.createdAt() ?: 0L) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(), - ) + epochDay(it.createdAt()) } val days = singleList.keys.sortedBy { it } days - .mapNotNull { string -> + .mapNotNull { day -> val sortedList = singleList - .get(string) + .get(day) ?.sortedWith(compareByDescending { it.createdAt() }.thenBy { it.idHex }) sortedList?.chunked(30)?.map { chunk -> @@ -312,12 +314,7 @@ class CardFeedContentState( .map { user -> val byDay = user.value.groupBy { - sdf.format( - Instant - .ofEpochSecond(it.createdAt() ?: 0L) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(), - ) + epochDay(it.createdAt()) } byDay.values.map { zaps -> @@ -338,12 +335,7 @@ class CardFeedContentState( .map { user -> val byDay = user.value.groupBy { - sdf.format( - Instant - .ofEpochSecond(it.createdAt() ?: 0L) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(), - ) + epochDay(it.createdAt()) } byDay.values.map { nutzaps -> From 2918dc549712263dcec1c597e47b0b9ecf540b61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 00:53:16 +0000 Subject: [PATCH 4/9] fix: replace LocalDate.ofInstant (API 34) with Instant.atZone chain (API 26+) --- .../ui/screen/loggedIn/notifications/CardFeedContentState.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 9cd127336b..4ed8adb6ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -273,7 +273,7 @@ class CardFeedContentState( // reaction/zap/repost — a meaningful GC saving on full feed conversions. val zone = ZoneId.systemDefault() - fun epochDay(createdAt: Long?): Long = LocalDate.ofInstant(Instant.ofEpochSecond(createdAt ?: 0L), zone).toEpochDay() + fun epochDay(createdAt: Long?): Long = Instant.ofEpochSecond(createdAt ?: 0L).atZone(zone).toLocalDate().toEpochDay() val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys + nutzapsPerEvent.keys val multiCards = From 43565beb2b7ccceea2ab6a3ead4579a12768df3a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 00:54:04 +0000 Subject: [PATCH 5/9] style: spotless formatting for CardFeedContentState --- .../screen/loggedIn/notifications/CardFeedContentState.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 4ed8adb6ae..0bbf0d54b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -62,7 +62,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.time.Instant -import java.time.LocalDate import java.time.ZoneId @Stable @@ -273,7 +272,12 @@ class CardFeedContentState( // reaction/zap/repost — a meaningful GC saving on full feed conversions. val zone = ZoneId.systemDefault() - fun epochDay(createdAt: Long?): Long = Instant.ofEpochSecond(createdAt ?: 0L).atZone(zone).toLocalDate().toEpochDay() + fun epochDay(createdAt: Long?): Long = + Instant + .ofEpochSecond(createdAt ?: 0L) + .atZone(zone) + .toLocalDate() + .toEpochDay() val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys + nutzapsPerEvent.keys val multiCards = From bd7c5c78cc4fdb10ddc028298e1bfb685a5fd779 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:10:10 +0000 Subject: [PATCH 6/9] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-zh-rCN/strings.xml | 8 ++++++++ docs/changelog/translators.json | 12 ++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index aa00d25fad..fe7b28ff70 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -566,6 +566,9 @@ 推荐的应用 选择您公开推荐的Nostr应用。建议显示在您的个人资料上,并帮助他人发现此客户端无法打开的内容。 尚未找到应用。应用在您的中继上被发现时会出现在这里。 + 按名称搜索应用 + 没有匹配您搜索的应用。 + 没有推荐应用匹配此过滤器。 未命名应用 不宣布它处理的内容 推荐 @@ -594,6 +597,8 @@ 重复 体重 + 练习 + 备注 小时 分钟 @@ -614,6 +619,9 @@ 冥想 饮食 节食 + 循环训练 + EMOM + AMRAP 应用 来源: %1$s v%1$s diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 1cf20fc728..31cf8a58bc 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -94,18 +94,18 @@ "Slovenian" ] }, - { - "user": "summoner001", - "languages": [ - "Hungarian" - ] - }, { "user": "hypnotichemionus4", "languages": [ "Chinese Simplified" ] }, + { + "user": "summoner001", + "languages": [ + "Hungarian" + ] + }, { "user": "vitorpamplona", "languages": [] From 4e582d198d229c85f054fcb328951751414517cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 00:22:21 +0000 Subject: [PATCH 7/9] fix(nutzaps): receive nutzaps on inbox/dm/kind:10019 relays, not outbox Inbound NIP-61 nutzaps (kind:9321) are messages other people send *to* the user, so per the NIP-65 outbox model they must be read from the user's inbox-side relays, not their outbox. The Cashu subscription used a single relay set (outbox) for both the user's own NIP-60 events and inbound nutzaps, so a sender following NIP-61 correctly (publishing to the relays advertised in the recipient's kind:10019, or to the recipient's NIP-65 inbox) could be missed. Split the subscription relay sets per filter: - own NIP-60 wallet/token/history events keep reading from outbox, where the user published them (needed to restore on a fresh device); - inbound kind:9321 nutzaps now read from the union of the user's NIP-65 inbox + DM relays + the `relay` tags in the user's own kind:10019. The last one is NIP-61's source of truth for "where to send me nutzaps" and may be written by another client to a relay set unrelated to our NIP-65 lists, so we listen there too. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8 --- .../vitorpamplona/amethyst/model/Account.kt | 2 + .../model/nip60Cashu/CashuWalletState.kt | 36 +++++++++++---- .../assemblers/CashuWalletFilterAssembler.kt | 46 +++++++++++++------ 3 files changed, 61 insertions(+), 23 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 5952610a09..6ac097df8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -425,6 +425,8 @@ class Account( scope = scope, assembler = cashuWalletFilterAssembler(), outboxRelaysFlow = outboxRelays.flow, + inboxRelaysFlow = notificationRelays.flow, + dmRelaysFlow = dmRelays.flow, settings = settings, okHttpClient = okHttpClientForMoney, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 938dccf661..7dc52b4ca1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -97,6 +97,8 @@ class CashuWalletState( private val scope: CoroutineScope, private val assembler: CashuWalletFilterAssembler, private val outboxRelaysFlow: StateFlow>, + private val inboxRelaysFlow: StateFlow>, + private val dmRelaysFlow: StateFlow>, private val settings: AccountSettings, okHttpClient: (String) -> OkHttpClient, ) { @@ -436,12 +438,31 @@ class CashuWalletState( triggerAutoRedeem() } - // Keep the relay subscription in sync with the outbox set. + // Keep the wallet subscription in sync with the relay sets it reads + // from. Following the NIP-65 outbox model, the two halves of the + // subscription read from different places: + // - our own NIP-60 events (wallet/token/history) are read back from + // our OUTBOX relays, where we published them; + // - inbound kind:9321 nutzaps are read from our INBOX set, since + // that is where other people deliver them. Per NIP-61 the source + // of truth for "where to send me nutzaps" is the `relay` tags in + // our own kind:10019 — and another client may have published that + // with relays unrelated to our NIP-65 lists — so we listen on the + // union of those plus our NIP-65 inbox + DM relays. jobs += scope.launch(Dispatchers.IO) { - outboxRelaysFlow.collect { relays -> - syncSubscription(relays) - } + combine( + outboxRelaysFlow, + inboxRelaysFlow, + dmRelaysFlow, + _nutzapInfoEvent, + ) { outbox, inbox, dm, info -> + CashuWalletQueryState( + pubkey = pubKey, + ownEventRelays = outbox, + inboxRelays = inbox + dm + (info?.relays() ?: emptyList()), + ) + }.collect { syncSubscription(it) } } // Reactive incremental update: any new event arrival that matches our @@ -504,17 +525,16 @@ class CashuWalletState( // ============================================================ // Subscription management // ============================================================ - private fun syncSubscription(relays: Set) { + private fun syncSubscription(next: CashuWalletQueryState) { val previous = currentSubscription - if (relays.isEmpty()) { + if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) { previous?.let { runCatching { assembler.unsubscribe(it) } } currentSubscription = null return } - if (previous != null && previous.relays == relays) return // unchanged + if (previous == next) return // unchanged previous?.let { runCatching { assembler.unsubscribe(it) } } - val next = CashuWalletQueryState(pubKey, relays) currentSubscription = next assembler.subscribe(next) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index d70325c0fc..c761981205 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -42,13 +42,23 @@ import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEven * Query state for the NIP-60 / NIP-61 wallet subscription. * * `pubkey` is the wallet owner — used both as `authors=` for their own - * NIP-60 events and as the `#p` tag value for inbound nutzaps. `relays` is - * the union of relays to subscribe on (NIP-65 outbox + DM relays at minimum). + * NIP-60 events and as the `#p` tag value for inbound nutzaps. + * + * The two filters read from different relay sets, following the NIP-65 + * outbox model: + * - [ownEventRelays] — the user's own write/outbox relays, where they + * published their NIP-60 wallet/token/history events. Restoring those + * means reading from where they were written. + * - [inboxRelays] — where *other* people deliver kind:9321 nutzaps to this + * user. Per NIP-61 the source of truth is the `relay` tags in the user's + * own kind:10019; in practice we listen on the union of those plus the + * user's NIP-65 inbox + DM relays so a nutzap can't slip past us. */ @Immutable data class CashuWalletQueryState( val pubkey: HexKey, - val relays: Set, + val ownEventRelays: Set, + val inboxRelays: Set, ) /** @@ -91,11 +101,9 @@ private class CashuWalletSubAssembler( if (keys.isEmpty()) return null val pubkey = keys.first().pubkey - val relays = - keys - .flatMap { it.relays } - .toSet() - .ifEmpty { return null } + val ownEventRelays = keys.flatMap { it.ownEventRelays }.toSet() + val inboxRelays = keys.flatMap { it.inboxRelays }.toSet() + if (ownEventRelays.isEmpty() && inboxRelays.isEmpty()) return null val ownedFilter = Filter( @@ -121,18 +129,26 @@ private class CashuWalletSubAssembler( tags = mapOf("p" to listOf(pubkey)), ) - return relays.flatMap { relay -> - val sinceTime = since?.get(relay)?.time - listOf( + // Own NIP-60 events are read from the user's outbox; inbound nutzaps + // from the user's inbox set. A relay that appears in both gets both + // filters. + val ownedSubs = + ownEventRelays.map { relay -> + val sinceTime = since?.get(relay)?.time RelayBasedFilter( relay, if (sinceTime != null) ownedFilter.copy(since = sinceTime) else ownedFilter, - ), + ) + } + val inboundSubs = + inboxRelays.map { relay -> + val sinceTime = since?.get(relay)?.time RelayBasedFilter( relay, if (sinceTime != null) inboundNutzapsFilter.copy(since = sinceTime) else inboundNutzapsFilter, - ), - ) - } + ) + } + + return ownedSubs + inboundSubs } } From 33fdd34cf0244d687c703e8fb6d3ae4b4e7c8543 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 01:12:20 +0000 Subject: [PATCH 8/9] fix(nutzaps): advertise inbox+dm relays in kind:10019, not outbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the NIP-65 outbox-model alignment for NIP-61: the `relay` tags in our kind:10019 are, per spec, the relays where the recipient *reads* incoming token events — i.e. inbox-side relays others publish to. We were advertising our outbox (write) relays there. Both publish paths (initial wallet creation in CashuWalletViewModel and P2PK key rotation in CashuWalletState.recreateNutzapKey) now advertise the union of our NIP-65 inbox + DM relays. This mirrors the relay set the wallet subscribes to for inbound kind:9321, so senders following our kind:10019 publish exactly where we listen. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8 --- .../amethyst/model/nip60Cashu/CashuWalletOps.kt | 8 +++++--- .../amethyst/model/nip60Cashu/CashuWalletState.kt | 5 ++++- .../ui/screen/loggedIn/wallet/CashuWalletViewModel.kt | 6 +++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt index bd8472aa6a..ae264b938d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt @@ -153,9 +153,11 @@ class CashuWalletOps( val walletEvent = signer.sign(walletTemplate) publish(walletEvent) - // Populate `relay` tags so senders know where to publish nutzaps — - // without these, they fall back to NIP-65 outbox and may miss our - // subscription scope on relays we don't read from. + // Populate `relay` tags so senders know where to publish nutzaps. + // Per NIP-61 these are the relays where the recipient reads incoming + // token events, so callers pass our inbox-side relays here (NIP-65 + // outbox model). Without them, senders fall back to NIP-65 and may + // publish where we don't subscribe for inbound nutzaps. val nutzapInfoTemplate = NutzapInfoEvent.build( mints = mints.map { NutzapMintTag(it, listOf("sat")) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 7dc52b4ca1..785b8a8cce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -895,7 +895,10 @@ class CashuWalletState( ops.publishWalletEvents( mints = currentMints, p2pkPrivkeyHex = manualPrivkeyHex?.takeIf { it.isNotBlank() }, - nutzapRelays = outboxRelaysFlow.value.toList(), + // Advertise our inbox + DM relays as the nutzap relays (NIP-65 + // outbox model): senders publish kind:9321 where we read inbound + // events, matching the wallet's inbound-nutzap subscription set. + nutzapRelays = (inboxRelaysFlow.value + dmRelaysFlow.value).toList(), ) // The NUT-13 seed (derived from the P2PK key) is invalidated by // applyEvents when the new kind:17375 round-trips in, so it re-derives diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index 0d0cd0ce79..7414865339 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -500,8 +500,12 @@ class CashuWalletViewModel : ViewModel() { ops.publishWalletEvents( mints = mints, p2pkPrivkeyHex = privkey, + // Advertise our inbox + DM relays as the nutzap relays, so + // senders publish kind:9321 where we read incoming events + // (NIP-65 outbox model). Mirrors the relay set the wallet + // subscribes to for inbound nutzaps. nutzapRelays = - acc.outboxRelays.flow.value + (acc.notificationRelays.flow.value + acc.dmRelays.flow.value) .toList(), ) _createState.value = CashuWalletCreateState.Success From cd14994859ab4d4f9739164d0ffccdadc3c7fea5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 01:16:24 +0000 Subject: [PATCH 9/9] fix(nutzaps): default kind:10019 relay tags to inbox list, drop dm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advertised `relay` tags in our own kind:10019 now copy the NIP-65 inbox relay list only, rather than inbox + DM. Inbox relays are the canonical "where others reach me" set, so they are the natural default for "where to send me nutzaps". DM relays stay in the wallet's inbound subscription as a safety net, but don't belong in the public kind:10019. The publish destination of the kind:10019 event itself is unchanged — it still broadcasts to our outbox via sendLiterallyEverywhere; only the relay tags inside the event changed. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8 --- .../amethyst/model/nip60Cashu/CashuWalletState.kt | 7 ++++--- .../ui/screen/loggedIn/wallet/CashuWalletViewModel.kt | 11 ++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 785b8a8cce..82961b923d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -895,10 +895,11 @@ class CashuWalletState( ops.publishWalletEvents( mints = currentMints, p2pkPrivkeyHex = manualPrivkeyHex?.takeIf { it.isNotBlank() }, - // Advertise our inbox + DM relays as the nutzap relays (NIP-65 + // Advertise our NIP-65 inbox relays as the nutzap relays (NIP-65 // outbox model): senders publish kind:9321 where we read inbound - // events, matching the wallet's inbound-nutzap subscription set. - nutzapRelays = (inboxRelaysFlow.value + dmRelaysFlow.value).toList(), + // events. We listen on a wider set (inbox + DM + these tags), but + // the kind:10019 default copies the inbox relay list, not outbox. + nutzapRelays = inboxRelaysFlow.value.toList(), ) // The NUT-13 seed (derived from the P2PK key) is invalidated by // applyEvents when the new kind:17375 round-trips in, so it re-derives diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index 7414865339..d04c5bf64f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -500,12 +500,13 @@ class CashuWalletViewModel : ViewModel() { ops.publishWalletEvents( mints = mints, p2pkPrivkeyHex = privkey, - // Advertise our inbox + DM relays as the nutzap relays, so - // senders publish kind:9321 where we read incoming events - // (NIP-65 outbox model). Mirrors the relay set the wallet - // subscribes to for inbound nutzaps. + // Advertise our NIP-65 inbox relays as the nutzap relays, + // so senders publish kind:9321 where we read incoming + // events (NIP-65 outbox model). The kind:10019 default + // copies the inbox relay list, not outbox; the wallet + // still subscribes to a wider inbox + DM + tags set. nutzapRelays = - (acc.notificationRelays.flow.value + acc.dmRelays.flow.value) + acc.notificationRelays.flow.value .toList(), ) _createState.value = CashuWalletCreateState.Success