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..2b7e1ebc47 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/AcceptedByRelaysGallery.kt @@ -0,0 +1,152 @@ +/* + * 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.State +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.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.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 + +/** + * 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 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. + // 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() } + return flow.collectAsStateWithLifecycle(initial) +} + +/** + * Lightweight "does this note have any relays" observer for the reaction row's + * expand-button gate, which runs for every note in the feed. Unlike + * [observeNoteRelays] it maps to a Boolean (no per-emission list allocation) and + * needs no throttling, since the emptiness flips at most a couple of times. + */ +@Composable +internal fun observeNoteHasRelays(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 + * paint below the author avatar; it now lives in the expanded reaction gallery so it + * is available to everyone, not just Complete mode. + */ +@Composable +internal fun RenderAcceptedByRelaysGallery( + relays: ImmutableList, + nav: INav, + accountViewModel: AccountViewModel, +) { + Row(Modifier.fillMaxWidth()) { + // 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), + 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 -> + // 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/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..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() } } @@ -564,8 +567,13 @@ private fun ReactionDetailGallery( val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } val hasReactions by observeNoteReferences(baseNote, accountViewModel) + val relays by observeNoteRelays(baseNote) - if (hasReactions) { + // 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), @@ -576,6 +584,9 @@ private fun ReactionDetailGallery( WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel) WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) + if (relays.isNotEmpty()) { + RenderAcceptedByRelaysGallery(relays, 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/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, ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7b28acf2fe..c4e62ddfd8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3123,6 +3123,7 @@ Profile Picture of %1$s Relay %1$s + Accepted by relays Expand relay list Note options Poll