mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge pull request #3736 from vitorpamplona/claude/buzz-community-redesign-vrovav
Redesign Buzz channel rows to match Concord style with activity previews
This commit is contained in:
+252
-75
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -31,17 +32,22 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -49,19 +55,44 @@ 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.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelHasUnreadFlow
|
||||
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 row in the "your channels on this workspace" section: a Buzz workspace channel the user is a
|
||||
* member of (via kind-44100). Tapping the card opens the channel ([onOpen]); the trailing Add
|
||||
* affordance appends it to the kind-10009 list so it surfaces in Messages / Relay Groups. Reused by
|
||||
* the relay group-list screen where Buzz membership discovery is folded in.
|
||||
* 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.
|
||||
* Tapping the card opens the channel ([onOpen]); the trailing overflow (3-dot) menu holds the
|
||||
* per-channel actions — Pin/Unpin and Add-to-my-list — so the row stays clean.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzImportRow(
|
||||
@@ -72,22 +103,73 @@ fun BuzzImportRow(
|
||||
onOpen: (() -> Unit)? = null,
|
||||
isStarred: Boolean = false,
|
||||
onToggleStar: (() -> Unit)? = null,
|
||||
showActivityPreview: Boolean = true,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
|
||||
|
||||
// Warm a first screen's worth of recent messages while this card is visible (content only — the
|
||||
// directory subscription already streams metadata), so the preview + facepile fill in ahead of a
|
||||
// tap instead of staying blank until the channel is opened. Bounded to visible rows by the
|
||||
// LazyColumn and released as they scroll off. Skipped for forum channels (no chat to warm).
|
||||
if (showActivityPreview) {
|
||||
RelayGroupCardWarmupSubscription(
|
||||
baseChannel,
|
||||
accountViewModel.dataSources().relayGroupCardWarmup,
|
||||
accountViewModel,
|
||||
contentOnly = true,
|
||||
contentLimit = CARD_WARMUP_LIMIT,
|
||||
)
|
||||
}
|
||||
|
||||
val channelState by observeChannel(baseChannel, accountViewModel)
|
||||
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val name = channel.toBestDisplayName()
|
||||
val memberCount = channel.memberCount()
|
||||
val isPrivate = channel.isPrivate()
|
||||
|
||||
// An unread dot when this group has chat newer than the last time this account opened it.
|
||||
val hasUnread by remember(groupId) {
|
||||
relayGroupChannelHasUnreadFlow(accountViewModel.account, groupId)
|
||||
}.collectAsStateWithLifecycle(false)
|
||||
// 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.
|
||||
val lastNote: Note?
|
||||
val faceAuthors: List<String>
|
||||
val unread: Int
|
||||
if (showActivityPreview) {
|
||||
val notesState by channel
|
||||
.flow()
|
||||
.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
|
||||
|
||||
val content =
|
||||
@Composable {
|
||||
BuzzImportRowContent(name, groupId.id, memberCount, isAdded, hasUnread, isStarred, onToggleStar, onAdd)
|
||||
BuzzImportRowContent(
|
||||
name = name,
|
||||
seed = groupId.id,
|
||||
isPrivate = isPrivate,
|
||||
memberCount = memberCount,
|
||||
lastNote = lastNote,
|
||||
faceAuthors = faceAuthors,
|
||||
unread = unread,
|
||||
hasUnread = hasUnread,
|
||||
isAdded = isAdded,
|
||||
isStarred = isStarred,
|
||||
onToggleStar = onToggleStar,
|
||||
onAdd = onAdd,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
if (onOpen != null) {
|
||||
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { content() }
|
||||
@@ -100,84 +182,179 @@ fun BuzzImportRow(
|
||||
private fun BuzzImportRowContent(
|
||||
name: String,
|
||||
seed: String,
|
||||
isPrivate: Boolean,
|
||||
memberCount: Int,
|
||||
isAdded: Boolean,
|
||||
lastNote: Note?,
|
||||
faceAuthors: List<String>,
|
||||
unread: Int,
|
||||
hasUnread: Boolean,
|
||||
isAdded: Boolean,
|
||||
isStarred: Boolean,
|
||||
onToggleStar: (() -> Unit)?,
|
||||
onAdd: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
|
||||
modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box {
|
||||
BuzzImportAvatar(name = name, seed = seed)
|
||||
if (hasUnread) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.size(11.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(2.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
BuzzImportAvatar(name = name, seed = seed)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
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.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (isPrivate) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = name,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (isStarred) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PushPin,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
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 = ""),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
ConcordUnreadBadge(unread)
|
||||
}
|
||||
}
|
||||
BuzzChannelRowMenu(
|
||||
isAdded = isAdded,
|
||||
onAdd = onAdd,
|
||||
isStarred = isStarred,
|
||||
onToggleStar = onToggleStar,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The line under a channel name: the last message's author + a snippet ("author: hello"), the Buzz
|
||||
* activity summary for a system/diff/job row, or — before anything has folded in — the member count
|
||||
* (or a muted "No messages yet"). Author names resolve reactively (hex → profile name).
|
||||
*/
|
||||
@Composable
|
||||
private fun BuzzChannelPreviewLine(
|
||||
lastNote: Note?,
|
||||
memberCount: Int,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = lastNote?.event
|
||||
val author = lastNote?.author
|
||||
val preview: String =
|
||||
if (event == null) {
|
||||
if (memberCount > 0) {
|
||||
Text(
|
||||
text = "$memberCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onToggleStar != null) {
|
||||
IconButton(onClick = onToggleStar) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PushPin,
|
||||
contentDescription = stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin),
|
||||
tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isAdded) {
|
||||
// Match the OutlinedButton's trailing content padding so the label doesn't jam against
|
||||
// the row edge (and doesn't jump horizontally) when Add flips to Added.
|
||||
Row(
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_import_added),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)
|
||||
} else {
|
||||
stringRes(R.string.relay_group_no_messages_yet)
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(onClick = onAdd) {
|
||||
Text(stringRes(R.string.buzz_import_add))
|
||||
// A Buzz timeline row (system line, huddle/job activity, diff) carries JSON/diff in its
|
||||
// content, so show its human-readable summary — the same text the in-chat row renders —
|
||||
// rather than "author: {json}". A plain chat message falls through to "author: message".
|
||||
val summary = buzzTimelinePreviewSummary(event, accountViewModel)
|
||||
when {
|
||||
summary != null -> summary
|
||||
author != null -> {
|
||||
val authorName by observeUserName(author, accountViewModel)
|
||||
val body = event.content.take(80)
|
||||
if (body.isBlank()) authorName else "$authorName: $body"
|
||||
}
|
||||
else -> event.content.take(80)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-channel overflow (3-dot) menu: Pin/Unpin and Add-to-my-list. Moved off the row itself so a
|
||||
* channel card reads as a clean Concord-style row, with its actions one tap behind the kebab.
|
||||
*/
|
||||
@Composable
|
||||
private fun BuzzChannelRowMenu(
|
||||
isAdded: Boolean,
|
||||
onAdd: () -> Unit,
|
||||
isStarred: Boolean,
|
||||
onToggleStar: (() -> Unit)?,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MoreVert,
|
||||
contentDescription = stringRes(R.string.more_options),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
if (onToggleStar != null) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PushPin,
|
||||
contentDescription = null,
|
||||
tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onToggleStar()
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
symbol = if (isAdded) MaterialSymbols.Check else MaterialSymbols.Add,
|
||||
contentDescription = null,
|
||||
tint = if (isAdded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
text = { Text(stringRes(if (isAdded) R.string.buzz_import_added else R.string.buzz_import_add)) },
|
||||
enabled = !isAdded,
|
||||
onClick = {
|
||||
expanded = false
|
||||
onAdd()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +375,7 @@ private fun BuzzImportAvatar(
|
||||
?.toString() ?: "#"
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(40.dp).clip(CircleShape).background(color),
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).background(color),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
|
||||
+34
-5
@@ -54,17 +54,24 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The Buzz workspace top-bar overflow (3-dot) menu. Holds the two workspace-owner actions that used
|
||||
* to sit inline above the channel list: "Add people to this workspace" (kind-9030, delegated to the
|
||||
* screen's [onAddPeople] dialog) and "Create invite link" (mints via the relay's `/api/invites`
|
||||
* endpoint — see [BuzzInviteMinter]). Any member sees both, but the relay only serves owners/admins,
|
||||
* so a rejection surfaces as the error dialog.
|
||||
* The Buzz workspace top-bar overflow (3-dot) menu. Holds the community-wide actions that used to sit
|
||||
* inline in the channel list:
|
||||
* - "Add all channels" ([onAddAll], shown only when some channels aren't in the user's list yet) —
|
||||
* appends every discovered channel to the kind-10009 list at once;
|
||||
* - "Agent Console" ([onOpenAgentConsole]) — the owner's per-community fleet console;
|
||||
* - "Add people to this workspace" (kind-9030, delegated to the screen's [onAddPeople] dialog);
|
||||
* - "Create invite link" (mints via the relay's `/api/invites` endpoint — see [BuzzInviteMinter]).
|
||||
*
|
||||
* Any member sees all of them, but the relay only serves the owner/admin ones, so a rejection
|
||||
* surfaces as the error dialog.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzWorkspaceOverflowMenu(
|
||||
relay: NormalizedRelayUrl,
|
||||
accountViewModel: AccountViewModel,
|
||||
onAddPeople: () -> Unit,
|
||||
onOpenAgentConsole: () -> Unit,
|
||||
onAddAll: (() -> Unit)? = null,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var minting by remember { mutableStateOf(false) }
|
||||
@@ -83,6 +90,28 @@ fun BuzzWorkspaceOverflowMenu(
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
if (onAddAll != null) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(symbol = MaterialSymbols.DoneAll, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
},
|
||||
text = { Text(stringRes(R.string.buzz_import_add_all)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onAddAll()
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(symbol = MaterialSymbols.AutoAwesome, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
},
|
||||
text = { Text(stringRes(R.string.buzz_console_card_title)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onOpenAgentConsole()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(symbol = MaterialSymbols.PersonAdd, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
|
||||
+72
-47
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
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.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
@@ -90,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzImportRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspaceOverflowMenu
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -271,7 +273,7 @@ fun RelayGroupChannelListScreen(
|
||||
text = relay.displayUrl(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
},
|
||||
showBackButton = canPop,
|
||||
@@ -282,6 +284,10 @@ fun RelayGroupChannelListScreen(
|
||||
relay = relay,
|
||||
accountViewModel = accountViewModel,
|
||||
onAddPeople = { showAddPeople = true },
|
||||
onOpenAgentConsole = { nav.nav(Route.AgentConsole(relay.url)) },
|
||||
// Only offer "Add all" when some discovered channel isn't in the user's
|
||||
// list yet — hidden once everything is already added.
|
||||
onAddAll = if (buzzChatChannels.any { it.id !in buzzAdded }) ({ buzzVm.addAll() }) else null,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -360,7 +366,7 @@ fun RelayGroupChannelListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// -- CHANNELS --
|
||||
// -- CHANNELS -- (Add-all now lives in the community's top-bar overflow menu)
|
||||
if (buzzChatChannels.isNotEmpty()) {
|
||||
val channelsCollapsed = "channels" in collapsedSections
|
||||
item(key = "sec-channels") {
|
||||
@@ -368,13 +374,7 @@ fun RelayGroupChannelListScreen(
|
||||
title = stringRes(R.string.relay_group_section_channels),
|
||||
collapsed = channelsCollapsed,
|
||||
onToggle = { toggleSection("channels") },
|
||||
) {
|
||||
if (buzzChatChannels.any { it.id !in buzzAdded }) {
|
||||
FilledTonalButton(onClick = { buzzVm.addAll() }) {
|
||||
Text(stringRes(R.string.buzz_import_add_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (!channelsCollapsed) {
|
||||
items(buzzChatChannels, key = { "chat-${it.id}" }) { groupId ->
|
||||
@@ -413,6 +413,9 @@ fun RelayGroupChannelListScreen(
|
||||
onOpen = { nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) },
|
||||
isStarred = groupId.id in starred,
|
||||
onToggleStar = { BuzzChannelStars.toggle(groupId.id) },
|
||||
// Forum posts live in a separate thread store, not the chat notes the
|
||||
// activity preview reads — so don't warm a kind-9 sub that returns nothing.
|
||||
showActivityPreview = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -456,11 +459,7 @@ fun RelayGroupChannelListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- AGENT CONSOLE -- (owner's per-community fleet console, footer)
|
||||
item(key = "sec-console") {
|
||||
AgentConsoleFooter { nav.nav(Route.AgentConsole(relay.url)) }
|
||||
}
|
||||
// Agent Console now lives in the community's top-bar overflow menu, not a footer card.
|
||||
} else {
|
||||
// Vanilla NIP-29 relay: flat channel directory (no forums/DMs/console).
|
||||
itemsIndexed(channels, key = { _, channel -> channel.groupId.id }) { index, channel ->
|
||||
@@ -565,8 +564,9 @@ private fun RelayGroupSectionHeader(
|
||||
|
||||
/**
|
||||
* One inline Direct-Message conversation row inside the community view: the counterpart's avatar +
|
||||
* name (or a "+N" cluster label for a group DM) and a compact last-activity time. Tapping opens the
|
||||
* DM as its relay-group chat.
|
||||
* name (or a "+N" cluster label for a group DM), a preview of the last message, and a compact
|
||||
* last-activity time. The channel's recent content is warmed while the row is visible so the preview
|
||||
* fills in ahead of a tap. Tapping opens the DM as its relay-group chat.
|
||||
*/
|
||||
@Composable
|
||||
private fun BuzzDmInlineRow(
|
||||
@@ -582,6 +582,23 @@ private fun BuzzDmInlineRow(
|
||||
val leadName by observeUserName(leadUser, accountViewModel)
|
||||
val label = if (others.size > 1) "$leadName +${others.size - 1}" else leadName
|
||||
|
||||
val account = accountViewModel.account
|
||||
val groupId = remember(row.channelId, row.relayUrl) { GroupId(row.channelId, row.relayUrl) }
|
||||
val channel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
|
||||
// Warm a screen's worth of recent DM messages while visible, so the preview isn't blank until opened.
|
||||
RelayGroupCardWarmupSubscription(
|
||||
channel,
|
||||
accountViewModel.dataSources().relayGroupCardWarmup,
|
||||
accountViewModel,
|
||||
contentOnly = true,
|
||||
contentLimit = CHANNEL_LIST_WARMUP_LIMIT,
|
||||
)
|
||||
val notesState by channel
|
||||
.flow()
|
||||
.notes.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val lastNote = remember(notesState) { channel.newestTimelineNote(account) }
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -593,19 +610,21 @@ private fun BuzzDmInlineRow(
|
||||
) {
|
||||
// Only show a presence dot for a 1:1 DM — a cluster avatar can't carry one peer's status.
|
||||
Box {
|
||||
UserPicture(leadHex, 40.dp, accountViewModel = accountViewModel, nav = nav)
|
||||
UserPicture(leadHex, 44.dp, accountViewModel = accountViewModel, nav = nav)
|
||||
if (others.size == 1) {
|
||||
PresenceDot(leadHex, Modifier.align(Alignment.BottomEnd), ringColor = MaterialTheme.colorScheme.surface)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
BuzzDmPreviewLine(lastNote, accountViewModel)
|
||||
}
|
||||
if (row.lastActivity > 0) {
|
||||
Text(
|
||||
text = timeAgoShort(row.lastActivity, stringRes(R.string.now)),
|
||||
@@ -616,6 +635,34 @@ private fun BuzzDmInlineRow(
|
||||
}
|
||||
}
|
||||
|
||||
/** The last-message preview under a DM row: "author: snippet", or nothing before any message loads. */
|
||||
@Composable
|
||||
private fun BuzzDmPreviewLine(
|
||||
lastNote: Note?,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = lastNote?.event ?: return
|
||||
val author = lastNote.author
|
||||
val summary = buzzTimelinePreviewSummary(event, accountViewModel)
|
||||
val preview: String =
|
||||
when {
|
||||
summary != null -> summary
|
||||
author != null -> {
|
||||
val authorName by observeUserName(author, accountViewModel)
|
||||
val body = event.content.take(80)
|
||||
if (body.isBlank()) authorName else "$authorName: $body"
|
||||
}
|
||||
else -> event.content.take(80)
|
||||
}
|
||||
Text(
|
||||
preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
/** The "See all N conversations" row that opens the full per-community DM inbox. */
|
||||
@Composable
|
||||
private fun SeeAllRow(
|
||||
@@ -632,28 +679,6 @@ private fun SeeAllRow(
|
||||
}
|
||||
}
|
||||
|
||||
/** The owner's per-community Agent Console entry, pinned as the footer of a Buzz community view. */
|
||||
@Composable
|
||||
private fun AgentConsoleFooter(onClick: () -> Unit) {
|
||||
Card(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 12.dp)) {
|
||||
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(symbol = MaterialSymbols.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(22.dp))
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringRes(R.string.buzz_console_card_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
stringRes(R.string.buzz_console_card_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayGroupChannelRow(
|
||||
channel: RelayGroupChannel,
|
||||
|
||||
+51
-2
@@ -26,6 +26,7 @@ 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
|
||||
@@ -111,11 +112,59 @@ 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,
|
||||
sinceSecs: Long,
|
||||
): Boolean =
|
||||
): Boolean = newMessagesSince(account, sinceSecs) > 0
|
||||
|
||||
/** The number of this group's timeline messages created strictly after [sinceSecs] (0 if none). */
|
||||
private fun RelayGroupChannel.newMessagesSince(
|
||||
account: Account,
|
||||
sinceSecs: Long,
|
||||
): Int =
|
||||
notes.count { _, note ->
|
||||
(note.createdAt() ?: 0L) > sinceSecs && isRelayGroupTimelineMessage(note, account)
|
||||
} > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The count of chat messages in [groupId] newer than the timestamp this account last read it — the
|
||||
* number the channel-row unread badge shows. Reactive: it recombines both when a fresh message folds
|
||||
* in (the channel's notes flow ticks) and when the user opens the group (which advances the last-read
|
||||
* marker), so opening a channel clears its badge. Mirrors [relayGroupChannelHasUnreadFlow].
|
||||
*/
|
||||
fun relayGroupChannelUnreadCountFlow(
|
||||
account: Account,
|
||||
groupId: GroupId,
|
||||
): Flow<Int> {
|
||||
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
|
||||
return combine(
|
||||
account.loadLastReadFlow(relayGroupChannelLastReadRoute(groupId)),
|
||||
channel.flow().notes.stateFlow,
|
||||
) { lastRead, _ ->
|
||||
channel.newMessagesSince(account, lastRead)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user