Merge pull request #3584 from vitorpamplona/claude/chatcompose-timeago-padding-v0yypr

Skip empty reaction chip rows on messages without engagement
This commit is contained in:
Vitor Pamplona
2026-07-15 19:36:31 -04:00
committed by GitHub
2 changed files with 45 additions and 1 deletions
@@ -238,6 +238,12 @@ fun NormalChatNote(
// carrying per-message metadata (expiration, geohash, PoW, legacy-DM marker).
val footerHasMeta = remember(note.event) { chatFooterHasMeta(note) }
// Only mount the reaction/zap chip row when the message actually has engagement.
// The chips overlap the bubble's bottom edge, so the bubble reserves space beneath
// the last line of text for them — without this gate every footerless bubble would
// reserve that space for an empty row, leaving a dead gap under the text.
val hasEngagement = !innerQuote && hasChatEngagement(note, accountViewModel)
ChatBubbleLayout(
isLoggedInUser = isLoggedInUser,
isDraft = note.event is DraftWrapEvent,
@@ -285,7 +291,7 @@ fun NormalChatNote(
)
},
reactionsRow =
if (!innerQuote) {
if (hasEngagement) {
{ ChatReactionChips(note, accountViewModel, nav) }
} else {
null
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCorner
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -89,6 +90,43 @@ private data class ReactionChip(
private val ChipGlyphFontSize = 13.sp
/**
* Whether [ChatReactionChips] would render anything for this message — a reaction, a
* zap, a pending zap, or a minichat-reply count. Mirrors the early-return gate in
* [RenderChatReactionChips] and keeps the same live subscriptions, so a message that
* gains its first reaction still flips this on. The bubble uses it to decide whether to
* even mount the chip row (and reserve overlap space beneath the text) instead of always
* paying for an empty row on every message.
*/
@Composable
fun hasChatEngagement(
baseNote: Note,
accountViewModel: AccountViewModel,
): Boolean {
val reactionsState by observeNoteReactions(baseNote, accountViewModel)
val hasReactions by
remember(reactionsState) {
derivedStateOf { (reactionsState?.note ?: baseNote).reactions.isNotEmpty() }
}
val zapsState by observeNoteZaps(baseNote, accountViewModel)
val hasZaps by
remember(zapsState) {
derivedStateOf { (zapsState?.note ?: baseNote).zaps.isNotEmpty() }
}
val supportsMinichat =
remember(baseNote) {
baseNote.inGatherers?.any { it is ConcordChannel || it is PublicChatChannel || it is RelayGroupChannel } == true
}
val minichatCount by if (supportsMinichat) observeNoteMinichatReplyCount(baseNote, accountViewModel) else remember { mutableStateOf(0) }
val zapsInFlight by accountViewModel.zapsInFlightFlow.collectAsStateWithLifecycle()
val isZapping = zapsInFlight.contains(baseNote.idHex)
return hasReactions || hasZaps || minichatCount > 0 || isZapping
}
/**
* Messenger-style pills under a chat bubble summarizing who engaged with the message:
* one chip per reaction emoji (with count, highlighted when the logged-in user is