Merge pull request #3814 from vitorpamplona/claude/corcord-buzz-height-difference-8fp108

Remove facepile from channel rows, simplify layout
This commit is contained in:
Vitor Pamplona
2026-07-29 15:41:17 -04:00
committed by GitHub
6 changed files with 29 additions and 150 deletions
@@ -58,26 +58,21 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordAuthorFacepile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordUnreadBadge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.newestTimelineNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.recentAuthorHexes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelUnreadCountFlow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/** How many recent-poster avatars a channel row's facepile shows at most. */
private const val FACEPILE_MAX = 4
/** A first screen's worth of recent messages to prefetch per visible card, so previews fill in. */
private const val CARD_WARMUP_LIMIT = 10
/**
* One channel row in a Buzz workspace's community view: a channel the user is a member of (via
* kind-44100), rendered like the Concord server view — a colored monogram, the channel name with a
* recent-posters facepile, a preview of the last message (author + snippet, or the Buzz activity
* summary for system/diff/job rows), the relative time of that message, and an unread-count badge.
* kind-44100), rendered like the Concord server view — a colored monogram, the channel name with the
* last message's relative time, and below it a preview of the last message (author + snippet, or the
* Buzz activity summary for system/diff/job rows) with an unread-count badge.
* Tapping the card opens the channel ([onOpen]); the row itself is a clean tap-to-open target — its
* per-channel actions (Pin/Unpin, Add/Remove-from-Messages) live in the opened channel's/forum's
* top-bar overflow, not on the row. A pinned channel still shows a pin marker here ([isStarred]).
@@ -85,10 +80,10 @@ private const val CARD_WARMUP_LIMIT = 10
* Reused by the relay group-list screen where Buzz membership discovery is folded in.
*
* [showActivityPreview] gates the chat-activity machinery — the recent-message warmup, the
* last-message preview, the recent-posters facepile and the unread badge. Enable it for **chat**
* channels (whose content lives in [RelayGroupChannel.notes]); leave it off for **forum** channels,
* whose posts are threads (a separate store), so the row doesn't open a kind-9 chat subscription that
* would return nothing and drives a member-count summary instead.
* last-message preview and the unread badge. Enable it for **chat** channels (whose content lives in
* [RelayGroupChannel.notes]); leave it off for **forum** channels, whose posts are threads (a
* separate store), so the row doesn't open a kind-9 chat subscription that would return nothing and
* drives a member-count summary instead.
*/
@Composable
fun BuzzImportRow(
@@ -122,11 +117,10 @@ fun BuzzImportRow(
val memberCount = channel.memberCount()
val isPrivate = channel.isPrivate()
// The channel's own notes flow drives the preview/facepile so they update the moment a message
// folds in, independent of the metadata-scoped [observeChannel] above. Only collected for chat
// channels; a forum row shows a member-count summary with no facepile/unread.
// The channel's own notes flow drives the preview so it updates the moment a message folds in,
// independent of the metadata-scoped [observeChannel] above. Only collected for chat channels; a
// forum row shows a member-count summary with no unread.
val lastNote: Note?
val faceAuthors: List<String>
val unread: Int
if (showActivityPreview) {
val notesState by channel
@@ -134,14 +128,12 @@ fun BuzzImportRow(
.notes.stateFlow
.collectAsStateWithLifecycle()
lastNote = remember(notesState) { channel.newestTimelineNote(account) }
faceAuthors = remember(notesState) { channel.recentAuthorHexes(account, FACEPILE_MAX) }
unread =
remember(groupId) { relayGroupChannelUnreadCountFlow(account, groupId) }
.collectAsStateWithLifecycle(0)
.value
} else {
lastNote = null
faceAuthors = emptyList()
unread = 0
}
val hasUnread = unread > 0
@@ -154,7 +146,6 @@ fun BuzzImportRow(
isPrivate = isPrivate,
memberCount = memberCount,
lastNote = lastNote,
faceAuthors = faceAuthors,
unread = unread,
hasUnread = hasUnread,
isStarred = isStarred,
@@ -179,7 +170,6 @@ private fun BuzzImportRowContent(
isPrivate: Boolean,
memberCount: Int,
lastNote: Note?,
faceAuthors: List<String>,
unread: Int,
hasUnread: Boolean,
isStarred: Boolean,
@@ -192,8 +182,8 @@ private fun BuzzImportRowContent(
BuzzImportAvatar(name = name, seed = seed)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
// Line 1: a lock (private), the channel name, a pin marker (starred), and the recent-
// posters facepile pushed to the right.
// Line 1: a lock (private), the channel name, a pin marker (starred), and the last-message
// time pushed to the right.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (isPrivate) {
Icon(
@@ -219,13 +209,6 @@ private fun BuzzImportRowContent(
modifier = Modifier.size(14.dp),
)
}
ConcordAuthorFacepile(faceAuthors, accountViewModel)
}
// Line 2: the last-message preview, then the time + unread badge.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
BuzzChannelPreviewLine(lastNote, memberCount, accountViewModel)
}
lastNote?.createdAt()?.let { ts ->
Text(
timeAgo(ts, LocalContext.current, prefix = ""),
@@ -234,6 +217,12 @@ private fun BuzzImportRowContent(
maxLines = 1,
)
}
}
// Line 2: the last-message preview, then the unread-message badge.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
BuzzChannelPreviewLine(lastNote, memberCount, accountViewModel)
}
ConcordUnreadBadge(unread)
}
}
@@ -46,7 +46,6 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -74,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader
@@ -223,7 +223,7 @@ fun ConcordChannelListScreen(
Scaffold(
topBar = {
TopAppBar(
ShorterTopAppBar(
title = { Text(communityName, maxLines = 1) },
navigationIcon = {
// Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place.
@@ -403,8 +403,6 @@ private fun ConcordChannelListRow(
remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) }
.collectAsStateWithLifecycle(0)
val hasUnread = unread > 0
// The recent posters' faces — recomputed as the channel's notes change (keyed on channelState).
val faceAuthors = remember(channelState) { channel.recentAuthorHexes(FACEPILE_MAX) }
Row(
Modifier
@@ -421,7 +419,7 @@ private fun ConcordChannelListRow(
tint = if (hasUnread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
// Line 1: channel name + the recent-posters facepile pushed to the right.
// Line 1: channel name + the last-message time pushed to the right.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
channelName,
@@ -431,13 +429,6 @@ private fun ConcordChannelListRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ConcordAuthorFacepile(faceAuthors, accountViewModel)
}
// Line 2: the last-message preview (or a live "typing…"), then the time + unread badge.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
}
lastNote?.createdAt()?.let { ts ->
Text(
timeAgo(ts, LocalContext.current, prefix = ""),
@@ -446,6 +437,12 @@ private fun ConcordChannelListRow(
maxLines = 1,
)
}
}
// Line 2: the last-message preview (or a live "typing…"), then the unread-message badge.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
}
ConcordUnreadBadge(unread)
}
}
@@ -458,9 +455,6 @@ private fun ConcordChannelListRow(
}
}
/** How many recent-poster avatars a channel row's facepile shows at most. */
private const val FACEPILE_MAX = 4
/**
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
@@ -1,60 +0,0 @@
/*
* 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.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A horizontal strip of the recent posters in a channel — the "who's here" cue that makes a busy
* channel feel alive. Each poster is drawn with the app's standard profile avatar
* ([ClickableUserPicture]), so it carries the same following badge (top-right) and trust-score tag
* (bottom-centre) shown everywhere else a user appears, instead of a bare cropped image. Laid out
* with a small gap rather than an overlapping stack so those badges stay readable; the newest poster
* is leftmost. Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally.
*/
@Composable
fun ConcordAuthorFacepile(
authorHexes: List<HexKey>,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
avatarSize: Dp = 24.dp,
maxShown: Int = 4,
) {
if (authorHexes.isEmpty()) return
val shown = authorHexes.take(maxShown)
Row(modifier, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
shown.forEach { hex ->
ClickableUserPicture(
baseUserHex = hex,
size = avatarSize,
accountViewModel = accountViewModel,
)
}
}
}
@@ -39,7 +39,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -68,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
@@ -115,7 +115,7 @@ fun ConcordHomeScreen(
Scaffold(
topBar = {
TopAppBar(
ShorterTopAppBar(
title = { Text(stringRes(R.string.concord_home_title)) },
navigationIcon = {
// Back arrow only when this is a pushed screen (from the drawer / a deep link);
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -135,22 +134,3 @@ private fun ConcordChannel.newMessagesSince(
notes.count { _, note ->
(note.createdAt() ?: 0L) > sinceSecs && isConcordTimelineMessage(note, account)
}
/**
* The pubkeys of the [limit] most-recent distinct posters in this channel, newest first — the
* facepile shown on a channel row. One O(notes) pass keeps each author's latest post time, so a
* chatty author counts once (at their newest message) rather than crowding out quieter voices.
*/
fun ConcordChannel.recentAuthorHexes(limit: Int): List<HexKey> {
val latestByAuthor = HashMap<HexKey, Long>()
for (note in notes.values()) {
val author = note.author?.pubkeyHex ?: continue
val at = note.createdAt() ?: continue
val prev = latestByAuthor[author]
if (prev == null || at > prev) latestByAuthor[author] = at
}
return latestByAuthor.entries
.sortedByDescending { it.value }
.take(limit)
.map { it.key }
}
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -112,29 +111,6 @@ fun RelayGroupChannel.newestTimelineNote(account: Account): Note? =
.sortedByDefaultFeedOrder()
.firstOrNull()
/**
* The pubkeys of the [limit] most-recent distinct posters in this group, newest first — the facepile
* shown on a channel row. One O(notes) pass keeps each author's latest post time, so a chatty author
* counts once (at their newest message) rather than crowding out quieter voices.
*/
fun RelayGroupChannel.recentAuthorHexes(
account: Account,
limit: Int,
): List<HexKey> {
val latestByAuthor = HashMap<HexKey, Long>()
for (note in notes.values()) {
if (!isRelayGroupTimelineMessage(note, account)) continue
val author = note.author?.pubkeyHex ?: continue
val at = note.createdAt() ?: continue
val prev = latestByAuthor[author]
if (prev == null || at > prev) latestByAuthor[author] = at
}
return latestByAuthor.entries
.sortedByDescending { it.value }
.take(limit)
.map { it.key }
}
/** Whether this group's message store holds any acceptable timeline message created after [sinceSecs]. */
private fun RelayGroupChannel.hasChatNewerThan(
account: Account,