Merge pull request #3624 from vitorpamplona/claude/relay-icons-reaction-gallery-x4uzmk

Move relay badges from author column to reaction gallery
This commit is contained in:
Vitor Pamplona
2026-07-17 18:31:10 -04:00
committed by GitHub
8 changed files with 191 additions and 84 deletions
@@ -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)
@@ -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<Placeable>(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<Placeable>(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)
@@ -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<ImmutableList<NormalizedRelayUrl>> {
// 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<Boolean> {
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<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
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,
)
}
}
}
}
@@ -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,
@@ -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)
}
}
}
}
@@ -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(
@@ -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,
)
}
}
+1
View File
@@ -3123,6 +3123,7 @@
<string name="profile_image_of_user">Profile Picture of %1$s</string>
<string name="relay_info">Relay %1$s</string>
<string name="accepted_by_relays">Accepted by relays</string>
<string name="expand_relay_list">Expand relay list</string>
<string name="note_options">Note options</string>
<string name="poll">Poll</string>