mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge pull request #3814 from vitorpamplona/claude/corcord-buzz-height-difference-8fp108
Remove facepile from channel rows, simplify layout
This commit is contained in:
+18
-29
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-15
@@ -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
|
||||
|
||||
-60
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
|
||||
-20
@@ -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 }
|
||||
}
|
||||
|
||||
-24
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user