From 2f3c09c41d2fd2b63fec3d459f422106e708d7a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 18:45:31 +0000 Subject: [PATCH 1/4] feat: move relay icons from author badge to reaction gallery Complete UI Mode used to paint the "seen on" relay pills below the author avatar (BadgeBox / RelayBadges), hidden from every other mode. As part of retiring Complete UI Mode, surface those relay icons for everyone instead: they now appear as an "Accepted by relays" line at the top of the expanded reaction gallery. - Add AcceptedByRelaysGallery: a reaction-gallery line (Dns icon + FlowRow of relay favicons) mirroring the OnchainZap/Nutzap gallery pattern, subscribing to the note's sampled relay flow. - Wire it as the first line of ReactionDetailGallery and drop the hasReactions gate so the line shows whenever the gallery is opened (a note almost always has at least one relay). - Remove the complete-mode-only BadgeBox and the now-dead RelayBadges composable and the relayBadges slot in NoteComposeLayout (reindexing the single-pass measure/place logic). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc --- .../amethyst/model/LocalCache.kt | 6 +- .../amethyst/ui/layouts/NoteComposeLayout.kt | 51 +++---- .../ui/note/AcceptedByRelaysGallery.kt | 125 ++++++++++++++++++ .../amethyst/ui/note/NoteCompose.kt | 20 --- .../amethyst/ui/note/ReactionsRow.kt | 28 ++-- .../amethyst/ui/note/RelayListBox.kt | 22 --- amethyst/src/main/res/values/strings.xml | 1 + 7 files changed, 160 insertions(+), 93 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 67be2a34c5..abddc36e62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1626,9 +1626,9 @@ object LocalCache : ILocalCache, ICacheProvider { ): Boolean { val note = getOrCreateNote(event.id) - // Approval notes are badge-rendered directly in community feeds - // (BadgeBox has no repost-style indirection for them), so without - // this attribution their relay list stays empty forever. + // Approval notes render their own relay list directly in community feeds + // (there is no repost-style indirection to a replyTo for them), so without + // this attribution the "accepted by relays" gallery line stays empty forever. if (relay != null) { getOrCreateUser(event.pubKey).addRelayBeingUsed(relay, event.createdAt) note.addRelay(relay) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt index ec6a9e72da..c47bfa608a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/NoteComposeLayout.kt @@ -54,8 +54,6 @@ private data class NoteLayoutPx( val authorWidth: Int, /** Horizontal gap between author column and content column: 10dp */ val authorContentGap: Int, - /** Vertical gap between author picture and relay badges: 5dp */ - val authorBadgeGap: Int, /** Vertical spacer between header rows and note content: 4dp */ val contentSpacer: Int, /** Left padding for the content area: 12dp */ @@ -87,9 +85,6 @@ private fun NoteComposeLayoutPreviewCard() { .background(Color(0xFF9575CD)), ) }, - relayBadges = { - Text("R1 R2 R3", fontSize = 10.sp, color = Color.Gray) - }, firstRow = { Text( "Alice @alice 2h ...", @@ -127,7 +122,6 @@ private fun NoteComposeLayoutBoostedPreview() { addPadding = false, showAuthorColumn = false, authorPicture = {}, - relayBadges = {}, firstRow = { Text("Bob boosted 2h ...") }, @@ -157,10 +151,8 @@ private fun NoteComposeLayoutBoostedPreview() { * │ │author│ gap │ firstRow │ │ * │ │ 55dp │ │ secondRow (optional) │ │ * │ │ │ │ 4dp spacer (optional) │ │ - * │ ├──────┤ 5dp │ noteContent │ │ - * │ │relay │ gap │ │ │ - * │ │badges│ │ │ │ - * │ └──────┘ └────────────────────────────┘ │ + * │ └──────┘ │ noteContent │ │ + * │ └────────────────────────────┘ │ * ├───────────────────────────────────────────────┤ * │ reactionsRow (full width, no side padding) │ * └───────────────────────────────────────────────┘ @@ -200,11 +192,10 @@ private fun NoteComposeLayoutBoostedPreview() { * * The `contents` list and `allMeasurables` indices are: * - 0: [authorPicture] - 0 or 1 measurables - * - 1: [relayBadges] - 0 or 1 measurables - * - 2: [firstRow] - exactly 1 measurable - * - 3: [secondRow] - 0 or 1 measurables - * - 4: [noteContent] - 1+ measurables (stacked vertically) - * - 5: [reactionsRow] - 0+ measurables (stacked vertically) + * - 1: [firstRow] - exactly 1 measurable + * - 2: [secondRow] - 0 or 1 measurables + * - 3: [noteContent] - 1+ measurables (stacked vertically) + * - 4: [reactionsRow] - 0+ measurables (stacked vertically) * * ## Performance * @@ -212,7 +203,7 @@ private fun NoteComposeLayoutBoostedPreview() { * - Eliminates 3 layout node levels (Row, author Column, content Column) * - Eliminates Row's two-pass measurement (measure author first, then content) * - Pre-computes all pixel dimensions once via [remember], cached across recompositions - * - Remaining per-frame allocations: ~48 bytes for `listOf(6 lambdas)` + + * - Remaining per-frame allocations: ~40 bytes for `listOf(5 lambdas)` + * ~24 bytes per `arrayOfNulls` for multi-child slots * * The caller should use `drawBehind { drawRect(color) }` instead of @@ -231,8 +222,6 @@ private fun NoteComposeLayoutBoostedPreview() { * note content. False for repost events. * @param authorPicture Slot for the author's profile picture (55x55dp area). * Should emit nothing when [showAuthorColumn] is false to skip composition. - * @param relayBadges Slot for relay indicator icons below the author picture. - * Should emit nothing when [showAuthorColumn] is false. * @param firstRow Slot for the primary header: author name, time, more options. * Always present. * @param secondRow Slot for the secondary header: NIP-05, location, PoW, OTS. @@ -250,7 +239,6 @@ fun NoteComposeLayout( showSecondRow: Boolean = false, showContentSpacer: Boolean = true, authorPicture: @Composable () -> Unit, - relayBadges: @Composable () -> Unit, firstRow: @Composable () -> Unit, secondRow: @Composable () -> Unit, noteContent: @Composable () -> Unit, @@ -263,7 +251,6 @@ fun NoteComposeLayout( NoteLayoutPx( authorWidth = Size55dp.roundToPx(), authorContentGap = 10.dp.roundToPx(), - authorBadgeGap = 5.dp.roundToPx(), contentSpacer = 4.dp.roundToPx(), padStart = 12.dp.roundToPx(), padEnd = 12.dp.roundToPx(), @@ -277,11 +264,10 @@ fun NoteComposeLayout( contents = listOf( authorPicture, // 0 - relayBadges, // 1 - firstRow, // 2 - secondRow, // 3 - noteContent, // 4 - reactionsRow, // 5 + firstRow, // 1 + secondRow, // 2 + noteContent, // 3 + reactionsRow, // 4 ), modifier = modifier, ) { allMeasurables, constraints -> @@ -308,14 +294,13 @@ fun NoteComposeLayout( // Single-child slots: firstOrNull() returns null for empty slots (hidden), // skipping measurement entirely. val authorPlaceable = allMeasurables[0].firstOrNull()?.measure(authorConstraints) - val relayPlaceable = allMeasurables[1].firstOrNull()?.measure(authorConstraints) - val firstRowPlaceable = allMeasurables[2].firstOrNull()?.measure(contentConstraints) + val firstRowPlaceable = allMeasurables[1].firstOrNull()?.measure(contentConstraints) val secondRowPlaceable = - if (showSecondRow) allMeasurables[3].firstOrNull()?.measure(contentConstraints) else null + if (showSecondRow) allMeasurables[2].firstOrNull()?.measure(contentConstraints) else null // Multi-child slots: measured into pre-sized arrays to avoid List allocation. - val contentMeasurables = allMeasurables[4] + val contentMeasurables = allMeasurables[3] val contentPlaceables = arrayOfNulls(contentMeasurables.size) var contentStackHeight = 0 for (i in contentMeasurables.indices) { @@ -324,7 +309,7 @@ fun NoteComposeLayout( contentStackHeight += placeable.height } - val reactionsMeasurables = allMeasurables[5] + val reactionsMeasurables = allMeasurables[4] val reactionsPlaceables = arrayOfNulls(reactionsMeasurables.size) var reactionsHeight = 0 for (i in reactionsMeasurables.indices) { @@ -344,7 +329,7 @@ fun NoteComposeLayout( val authorColumnHeight = if (showAuthorColumn && authorPlaceable != null) { - authorPlaceable.height + px.authorBadgeGap + (relayPlaceable?.height ?: 0) + authorPlaceable.height } else { 0 } @@ -357,10 +342,6 @@ fun NoteComposeLayout( // Author column (inside padding area) if (showAuthorColumn) { authorPlaceable?.placeRelative(padStart, padTop) - relayPlaceable?.placeRelative( - padStart, - padTop + (authorPlaceable?.height ?: 0) + px.authorBadgeGap, - ) } // Content column (to the right of author, inside padding area) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt new file mode 100644 index 0000000000..01d75014c0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt @@ -0,0 +1,125 @@ +/* + * 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.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.StdStartPadding +import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.sample + +/** + * The "accepted by relays" line of the reaction gallery: the favicon of every relay + * the note was seen on. This is the same relay-pill set that Complete UI Mode used to + * paint below the author avatar; it now lives in the expanded reaction gallery so it + * is available to everyone, not just Complete mode. + */ +@OptIn(FlowPreview::class) +@Composable +internal fun WatchRelaysAndRenderGallery( + baseNote: Note, + nav: INav, + accountViewModel: AccountViewModel, +) { + // Cold wrapper: `flow()` must be re-resolved on every collection start. A memory + // trim destroys the NoteFlowSet while the lifecycle is stopped; a stateFlow + // captured in remember would then be orphaned and never see another relay update. + // Sampled at 500ms because relay arrivals churn while a note is actively fanning out. + val flow = + remember(baseNote) { + flow { emitAll(baseNote.flow().relays.stateFlow) } + .sample(500) + .map { it.note.relays.toImmutableList() } + .distinctUntilChanged() + } + + val initial = remember(baseNote) { baseNote.relays.toImmutableList() } + val relays by flow.collectAsStateWithLifecycle(initial) + + if (relays.isNotEmpty()) { + RenderAcceptedByRelaysGallery(relays, nav, accountViewModel) + } +} + +@Composable +private fun RenderAcceptedByRelaysGallery( + relays: ImmutableList, + nav: INav, + accountViewModel: AccountViewModel, +) { + Row(Modifier.fillMaxWidth()) { + Box(modifier = WidthAuthorPictureModifier) { + Icon( + symbol = MaterialSymbols.Dns, + contentDescription = stringRes(id = R.string.accepted_by_relays), + modifier = Modifier.size(Size20dp).align(Alignment.TopEnd), + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + + RelayGallery(relays, nav, accountViewModel) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RelayGallery( + relays: ImmutableList, + nav: INav, + accountViewModel: AccountViewModel, +) { + Column(modifier = StdStartPadding) { + FlowRow { + relays.forEach { relay -> + RenderRelay(relay, accountViewModel, nav) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 5ae73506f1..4058010da9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -745,11 +745,6 @@ fun InnerNoteWithReactions( } } }, - relayBadges = { - if (notBoostedNorQuote) { - BadgeBox(baseNote, accountViewModel, nav) - } - }, firstRow = { FirstUserInfoRow( baseNote = baseNote, @@ -2075,21 +2070,6 @@ fun observeEdits( return editState } -@Composable -fun BadgeBox( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (accountViewModel.settings.isCompleteUIMode()) { - if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { - baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it, accountViewModel, nav) } - } else { - RelayBadges(baseNote, accountViewModel, nav) - } - } -} - @Composable fun RenderAuthorImages( baseNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 095037c07a..cbc89c4e63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -563,20 +563,22 @@ private fun ReactionDetailGallery( val defaultBackgroundColor = MaterialTheme.colorScheme.background val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val hasReactions by observeNoteReferences(baseNote, accountViewModel) + // Keep subscribing for reaction/zap/boost arrivals, but no longer gate the gallery + // on them: the "accepted by relays" line is (almost) always present, so once the + // user expands the gallery there is always at least that line to show. + observeNoteReferences(baseNote, accountViewModel) - if (hasReactions) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(start = 10.dp, top = 5.dp), - ) { - Column { - WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) - WatchNutzapsAndRenderGallery(baseNote, nav, accountViewModel) - WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel) - WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) - WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) - } + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(start = 10.dp, top = 5.dp), + ) { + Column { + WatchRelaysAndRenderGallery(baseNote, nav, accountViewModel) + WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) + WatchNutzapsAndRenderGallery(baseNote, nav, accountViewModel) + WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel) + WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) + WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt index 960c887c81..664193840c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt @@ -37,7 +37,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,7 +56,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer import com.vitorpamplona.amethyst.ui.theme.Size17Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.noteComposeRelayBox import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.FlowPreview @@ -67,26 +65,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.sample -@Composable -fun RelayBadges( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val expanded = remember { mutableStateOf(false) } - - CrossfadeIfEnabled(expanded.value, modifier = noteComposeRelayBox, label = "RelayBadges", accountViewModel = accountViewModel) { - if (it) { - RenderAllRelayList(baseNote, Modifier.fillMaxWidth(), accountViewModel = accountViewModel, nav = nav) - } else { - Column { - RenderClosedRelayList(baseNote, Modifier.fillMaxWidth(), accountViewModel = accountViewModel, nav = nav) - ShouldShowExpandButton(baseNote, accountViewModel) { ShowMoreRelaysButton { expanded.value = true } } - } - } - } -} - @OptIn(ExperimentalLayoutApi::class, FlowPreview::class) @Composable fun RenderAllRelayList( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 348b08fecc..cef539963f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3105,6 +3105,7 @@ Profile Picture of %1$s Relay %1$s + Accepted by relays Expand relay list Note options Poll From ede4fea1cdb150091fbb0eaa21cc8b858c9cb52e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 20:28:16 +0000 Subject: [PATCH 2/4] fix: don't render empty reaction gallery row without relays or reactions Restore a visibility guard on the reaction detail gallery so a note with neither relays nor reactions no longer paints an empty padded row when the gallery is expanded. The relay flow is now collected once via observeNoteRelays and reused both for the guard and to render the "accepted by relays" line, avoiding a second subscription. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc --- .../ui/note/AcceptedByRelaysGallery.kt | 30 ++++++++-------- .../amethyst/ui/note/ReactionsRow.kt | 36 +++++++++++-------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt index 01d75014c0..b0e29acd7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt @@ -29,7 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.State import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -56,18 +56,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.sample /** - * The "accepted by relays" line of the reaction gallery: the favicon of every relay - * the note was seen on. This is the same relay-pill set that Complete UI Mode used to - * paint below the author avatar; it now lives in the expanded reaction gallery so it - * is available to everyone, not just Complete mode. + * Observes the relays the note was seen on, throttled and de-duplicated for the + * "accepted by relays" gallery line. Exposed as State so the caller can both gate + * the gallery's visibility on it and feed it to [RenderAcceptedByRelaysGallery] + * from a single subscription. */ @OptIn(FlowPreview::class) @Composable -internal fun WatchRelaysAndRenderGallery( - baseNote: Note, - nav: INav, - accountViewModel: AccountViewModel, -) { +internal fun observeNoteRelays(baseNote: Note): State> { // Cold wrapper: `flow()` must be re-resolved on every collection start. A memory // trim destroys the NoteFlowSet while the lifecycle is stopped; a stateFlow // captured in remember would then be orphaned and never see another relay update. @@ -81,15 +77,17 @@ internal fun WatchRelaysAndRenderGallery( } val initial = remember(baseNote) { baseNote.relays.toImmutableList() } - val relays by flow.collectAsStateWithLifecycle(initial) - - if (relays.isNotEmpty()) { - RenderAcceptedByRelaysGallery(relays, nav, accountViewModel) - } + return flow.collectAsStateWithLifecycle(initial) } +/** + * The "accepted by relays" line of the reaction gallery: the favicon of every relay + * the note was seen on. This is the same relay-pill set that Complete UI Mode used to + * paint below the author avatar; it now lives in the expanded reaction gallery so it + * is available to everyone, not just Complete mode. + */ @Composable -private fun RenderAcceptedByRelaysGallery( +internal fun RenderAcceptedByRelaysGallery( relays: ImmutableList, nav: INav, accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index cbc89c4e63..46aaad443c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -563,22 +563,28 @@ private fun ReactionDetailGallery( val defaultBackgroundColor = MaterialTheme.colorScheme.background val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - // Keep subscribing for reaction/zap/boost arrivals, but no longer gate the gallery - // on them: the "accepted by relays" line is (almost) always present, so once the - // user expands the gallery there is always at least that line to show. - observeNoteReferences(baseNote, accountViewModel) + val hasReactions by observeNoteReferences(baseNote, accountViewModel) + val relays by observeNoteRelays(baseNote) - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(start = 10.dp, top = 5.dp), - ) { - Column { - WatchRelaysAndRenderGallery(baseNote, nav, accountViewModel) - WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) - WatchNutzapsAndRenderGallery(baseNote, nav, accountViewModel) - WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel) - WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) - WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) + // The gallery shows whenever there is anything to display: the "accepted by relays" + // line (relays are almost always present once a note is seen) or any zap/boost/ + // reaction line. Guarding here keeps the padded Row from rendering an empty strip + // for a note that has neither. + if (hasReactions || relays.isNotEmpty()) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(start = 10.dp, top = 5.dp), + ) { + Column { + if (relays.isNotEmpty()) { + RenderAcceptedByRelaysGallery(relays, nav, accountViewModel) + } + WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) + WatchNutzapsAndRenderGallery(baseNote, nav, accountViewModel) + WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel) + WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) + WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) + } } } } From 76dd0a0f16c17a6544e287ae5b024cb3f3a5aa18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 21:11:34 +0000 Subject: [PATCH 3/4] fix: size accepted-by-relays icons to match gallery author pictures The relay favicons rendered at 17dp while the zap/boost/reaction gallery rows use 35dp author pictures, so the "accepted by relays" line looked out of scale. Parameterize RenderRelay with a box size and icon modifier (defaults unchanged for the existing compact relay lists) and render the gallery relays at 35dp with MediumRelayIconModifier to match. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc --- .../amethyst/ui/note/AcceptedByRelaysGallery.kt | 11 ++++++++++- .../vitorpamplona/amethyst/ui/note/RelayListRow.kt | 8 ++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt index b0e29acd7b..5ca0da3763 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt @@ -41,7 +41,9 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdStartPadding import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -116,7 +118,14 @@ private fun RelayGallery( Column(modifier = StdStartPadding) { FlowRow { relays.forEach { relay -> - RenderRelay(relay, accountViewModel, nav) + // Match the 35dp author pictures of the sibling zap/boost/reaction lines. + RenderRelay( + relay = relay, + accountViewModel = accountViewModel, + nav = nav, + boxSize = Size35dp, + iconModifier = MediumRelayIconModifier, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index 1d16720674..1ff89c1a5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -128,15 +129,17 @@ fun RenderRelay( relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, nav: INav, + boxSize: Dp = Size17dp, + iconModifier: Modifier = MaterialTheme.colorScheme.relayIconModifier, ) { val relayInfo by loadRelayInfo(relay) val clipboardManager = LocalClipboard.current val scope = rememberCoroutineScope() val clickableModifier = - remember(relay) { + remember(relay, boxSize) { Modifier - .size(Size17dp) + .size(boxSize) .combinedClickable( indication = ripple24dp, interactionSource = MutableInteractionSource(), @@ -159,6 +162,7 @@ fun RenderRelay( loadProfilePicture = accountViewModel.settings.showProfilePictures(), pingInMs = 0, loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + iconModifier = iconModifier, ) } } From ed9e02ac7f15a0b33d2856023164331542a6f188 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 22:20:49 +0000 Subject: [PATCH 4/4] fix: reorder relay line last, align its icon, show expand button for relays Address gallery layout feedback for the "accepted by relays" line: - Move the relay line to the bottom of the reaction detail gallery. - Use NotificationIconModifier for the Dns icon box so it lines up horizontally with the zap/like/nutzap category icons (which carry the same 5dp end padding). - Show the reaction-gallery expand button whenever the note has relay URLs, not only when it has reactions/zaps/boosts, so the always-present relay line is reachable. Gate it with a lightweight boolean relay observer (no list allocation, no sampling) since it runs per feed note. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc --- .../ui/note/AcceptedByRelaysGallery.kt | 24 +++++++++++++++++-- .../amethyst/ui/note/ReactionsRow.kt | 11 +++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt index 5ca0da3763..2b7e1ebc47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt @@ -42,10 +42,10 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier +import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdStartPadding -import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.collections.immutable.ImmutableList @@ -82,6 +82,24 @@ internal fun observeNoteRelays(baseNote: Note): State { + val flow = + remember(baseNote) { + flow { emitAll(baseNote.flow().relays.stateFlow) } + .map { it.note.relays.isNotEmpty() } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(baseNote.relays.isNotEmpty()) +} + /** * The "accepted by relays" line of the reaction gallery: the favicon of every relay * the note was seen on. This is the same relay-pill set that Complete UI Mode used to @@ -95,7 +113,9 @@ internal fun RenderAcceptedByRelaysGallery( accountViewModel: AccountViewModel, ) { Row(Modifier.fillMaxWidth()) { - Box(modifier = WidthAuthorPictureModifier) { + // NotificationIconModifier (55dp wide, 5dp end padding) matches the category-icon + // column of the zap/like/nutzap gallery rows so the Dns icon lines up with them. + Box(modifier = NotificationIconModifier) { Icon( symbol = MaterialSymbols.Dns, contentDescription = stringRes(id = R.string.accepted_by_relays), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 46aaad443c..188994e900 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -523,10 +523,13 @@ private fun WatchReactionsZapsBoostsAndDisplayIfExists( content: @Composable () -> Unit, ) { val hasReactions by observeNoteReferences(baseNote, accountViewModel) + val hasRelays by observeNoteHasRelays(baseNote) val hasZapraiser = (baseNote.event?.zapraiserAmount() ?: 0) > 0 - if (hasReactions || hasZapraiser) { + // The gallery always carries an "accepted by relays" line when the note has been + // seen on any relay, so the expand button must be reachable in that case too. + if (hasReactions || hasZapraiser || hasRelays) { content() } } @@ -576,14 +579,14 @@ private fun ReactionDetailGallery( modifier = Modifier.padding(start = 10.dp, top = 5.dp), ) { Column { - if (relays.isNotEmpty()) { - RenderAcceptedByRelaysGallery(relays, nav, accountViewModel) - } WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) WatchNutzapsAndRenderGallery(baseNote, nav, accountViewModel) WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel) WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) + if (relays.isNotEmpty()) { + RenderAcceptedByRelaysGallery(relays, nav, accountViewModel) + } } } }