mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat: modernize Buzz community screens
Redesign the Buzz workspace community view to feel closer to the Concord server view — richer, more informative channel and DM rows, with actions tucked behind overflow menus so the list reads cleanly. - Title bar now uses a middle ellipsis on the community name so both ends stay visible instead of truncating the tail. - Channel cards (BuzzImportRow) now show a last-message preview (author + snippet, or the Buzz activity summary for system/diff/job rows), a recent-posters facepile, an unread-count badge, and the last-activity time — reusing the Concord facepile/unread-badge composables. Each card warms its recent messages while visible so previews fill in ahead of a tap. - Pin/Unpin and Add-to-my-list move off the channel row into a per-channel 3-dot overflow menu. - DM rows gain the same last-message preview line. - Add-all and Agent Console move from the inline header button / footer card into the community's top-bar 3-dot overflow menu. - Add relay-group timeline helpers (newestTimelineNote, recentAuthorHexes, relayGroupChannelUnreadCountFlow) mirroring the Concord ones. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89
This commit is contained in:
+226
-76
@@ -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,38 @@ 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.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzImportRow(
|
||||
@@ -73,21 +98,57 @@ fun BuzzImportRow(
|
||||
isStarred: Boolean = false,
|
||||
onToggleStar: (() -> Unit)? = null,
|
||||
) {
|
||||
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.
|
||||
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
|
||||
// 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.
|
||||
val notesState by channel
|
||||
.flow()
|
||||
.notes.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val name = channel.toBestDisplayName()
|
||||
val memberCount = channel.memberCount()
|
||||
|
||||
// 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)
|
||||
val isPrivate = channel.isPrivate()
|
||||
val lastNote = remember(notesState) { channel.newestTimelineNote(account) }
|
||||
val faceAuthors = remember(notesState) { channel.recentAuthorHexes(account, FACEPILE_MAX) }
|
||||
val unread by
|
||||
remember(groupId) { relayGroupChannelUnreadCountFlow(account, groupId) }
|
||||
.collectAsStateWithLifecycle(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,83 +161,172 @@ 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
|
||||
// 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 the usual "author: message" framing.
|
||||
val summary = remember(event) { event?.let { buzzTimelinePreviewSummary(it) } }
|
||||
val preview: String =
|
||||
when {
|
||||
summary != null -> summary
|
||||
event != null && author != null -> {
|
||||
val authorName by observeUserName(author, accountViewModel)
|
||||
val body = event.content.take(80)
|
||||
if (body.isBlank()) authorName else "$authorName: $body"
|
||||
}
|
||||
event != null -> event.content.take(80)
|
||||
memberCount > 0 -> pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)
|
||||
else -> stringRes(R.string.relay_group_no_messages_yet)
|
||||
}
|
||||
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),
|
||||
)
|
||||
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),
|
||||
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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(onClick = onAdd) {
|
||||
Text(stringRes(R.string.buzz_import_add))
|
||||
}
|
||||
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 +348,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))
|
||||
|
||||
+69
-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 ->
|
||||
@@ -456,11 +456,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 +561,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 +579,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 +607,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 +632,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 = remember(event) { buzzTimelinePreviewSummary(event) }
|
||||
val preview: String =
|
||||
if (summary != null) {
|
||||
summary
|
||||
} else if (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 +676,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,
|
||||
|
||||
+77
-3
@@ -20,9 +20,12 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
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
|
||||
@@ -82,7 +85,78 @@ fun relayGroupServerHasUnreadFlow(
|
||||
private fun RelayGroupChannel.hasChatNewerThan(
|
||||
account: Account,
|
||||
sinceSecs: Long,
|
||||
): Boolean =
|
||||
): Boolean = newMessagesSince(account, sinceSecs) > 0
|
||||
|
||||
/**
|
||||
* True for a note the group's chat *timeline* actually renders — an acceptable (not muted/blocked)
|
||||
* message whose event is real group-chat content ([isGroupChatContent], which also folds in the Buzz
|
||||
* dialect's stream/system/activity rows). Every list-row surface that summarizes a channel — the
|
||||
* unread badge ([relayGroupChannelUnreadCountFlow]), the last-message preview + time
|
||||
* ([newestTimelineNote]) and the recent-posters facepile ([recentAuthorHexes]) — reuses this so none
|
||||
* of them can disagree with the open channel's feed or with the unread dot.
|
||||
*/
|
||||
private fun isRelayGroupTimelineMessage(
|
||||
note: Note,
|
||||
account: Account,
|
||||
): Boolean = note.event?.isGroupChatContent() == true && account.isAcceptable(note)
|
||||
|
||||
/**
|
||||
* The newest timeline message in this group (see [isRelayGroupTimelineMessage]), or null if none —
|
||||
* the note a list row shows as the channel's "last message". Skips reactions/metadata and hidden
|
||||
* authors so the preview matches what the channel feed renders and the unread badge counts.
|
||||
*/
|
||||
fun RelayGroupChannel.newestTimelineNote(account: Account): Note? =
|
||||
notes
|
||||
.filter { _, note -> isRelayGroupTimelineMessage(note, account) }
|
||||
.minWithOrNull(Channel.DefaultFeedOrder)
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
|
||||
/** 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 && account.isAcceptable(note) && note.event?.isGroupChatContent() == true
|
||||
} > 0
|
||||
(note.createdAt() ?: 0L) > sinceSecs && isRelayGroupTimelineMessage(note, account)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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